diff --git a/account.go b/account.go index 97d3a62..e902b5b 100644 --- a/account.go +++ b/account.go @@ -1,1264 +1,1264 @@ /* * Copyright © 2018-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "encoding/json" "fmt" "html/template" "net/http" "regexp" "strconv" "strings" "sync" "time" "github.com/gorilla/csrf" "github.com/gorilla/mux" "github.com/gorilla/sessions" "github.com/guregu/null/zero" "github.com/writeas/impart" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/data" "github.com/writeas/web-core/log" "github.com/writefreely/writefreely/author" "github.com/writefreely/writefreely/config" "github.com/writefreely/writefreely/page" ) type ( userSettings struct { Username string `schema:"username" json:"username"` Email string `schema:"email" json:"email"` NewPass string `schema:"new-pass" json:"new_pass"` OldPass string `schema:"current-pass" json:"current_pass"` IsLogOut bool `schema:"logout" json:"logout"` } UserPage struct { page.StaticPage PageTitle string Separator template.HTML IsAdmin bool CanInvite bool CollAlias string } ) func NewUserPage(app *App, r *http.Request, u *User, title string, flashes []string) *UserPage { up := &UserPage{ StaticPage: pageForReq(app, r), PageTitle: title, } up.Username = u.Username up.Flashes = flashes up.Path = r.URL.Path up.IsAdmin = u.IsAdmin() up.CanInvite = canUserInvite(app.cfg, up.IsAdmin) return up } func canUserInvite(cfg *config.Config, isAdmin bool) bool { return cfg.App.UserInvites != "" && (isAdmin || cfg.App.UserInvites != "admin") } func (up *UserPage) SetMessaging(u *User) { // up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID) } const ( loginAttemptExpiration = 3 * time.Second ) var actuallyUsernameReg = regexp.MustCompile("username is actually ([a-z0-9\\-]+)\\. Please try that, instead") func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error { _, err := signup(app, w, r) return err } func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) { if app.cfg.App.DisablePasswordAuth { err := ErrDisabledPasswordAuth return nil, err } reqJSON := IsJSON(r) // Get params var ur userRegistration if reqJSON { decoder := json.NewDecoder(r.Body) err := decoder.Decode(&ur) if err != nil { log.Error("Couldn't parse signup JSON request: %v\n", err) return nil, ErrBadJSON } } else { // Check if user is already logged in u := getUserSession(app, r) if u != nil { return &AuthUser{User: u}, nil } err := r.ParseForm() if err != nil { log.Error("Couldn't parse signup form request: %v\n", err) return nil, ErrBadFormData } err = app.formDecoder.Decode(&ur, r.PostForm) if err != nil { log.Error("Couldn't decode signup form request: %v\n", err) return nil, ErrBadFormData } } return signupWithRegistration(app, ur, w, r) } func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWriter, r *http.Request) (*AuthUser, error) { reqJSON := IsJSON(r) // Validate required params (alias) if signup.Alias == "" { return nil, impart.HTTPError{http.StatusBadRequest, "A username is required."} } if signup.Pass == "" { return nil, impart.HTTPError{http.StatusBadRequest, "A password is required."} } var desiredUsername string if signup.Normalize { // With this option we simply conform the username to what we expect // without complaining. Since they might've done something funny, like // enter: write.as/Way Out There, we'll use their raw input for the new // collection name and sanitize for the slug / username. desiredUsername = signup.Alias signup.Alias = getSlug(signup.Alias, "") } if !author.IsValidUsername(app.cfg, signup.Alias) { // Ensure the username is syntactically correct. return nil, impart.HTTPError{http.StatusPreconditionFailed, "Username is reserved or isn't valid. It must be at least 3 characters long, and can only include letters, numbers, and hyphens."} } // Handle empty optional params hashedPass, err := auth.HashPass([]byte(signup.Pass)) if err != nil { return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} } // Create struct to insert u := &User{ Username: signup.Alias, HashedPass: hashedPass, HasPass: true, Email: prepareUserEmail(signup.Email, app.keys.EmailKey), Created: time.Now().Truncate(time.Second).UTC(), } // Create actual user if err := app.db.CreateUser(app.cfg, u, desiredUsername, signup.Description); err != nil { return nil, err } // Log invite if needed if signup.InviteCode != "" { err = app.db.CreateInvitedUser(signup.InviteCode, u.ID) if err != nil { return nil, err } } // Add back unencrypted data for response if signup.Email != "" { u.Email.String = signup.Email } resUser := &AuthUser{ User: u, } title := signup.Alias if signup.Normalize { title = desiredUsername } resUser.Collections = &[]Collection{ { Alias: signup.Alias, Title: title, Description: signup.Description, }, } var coll *Collection if signup.Monetization != "" { if coll == nil { coll, err = app.db.GetCollection(signup.Alias) if err != nil { log.Error("Unable to get new collection '%s' for monetization on signup: %v", signup.Alias, err) return nil, err } } err = app.db.SetCollectionAttribute(coll.ID, "monetization_pointer", signup.Monetization) if err != nil { log.Error("Unable to add monetization on signup: %v", err) return nil, err } coll.Monetization = signup.Monetization } var token string if reqJSON && !signup.Web { token, err = app.db.GetAccessToken(u.ID) if err != nil { return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create access token. Try re-authenticating."} } resUser.AccessToken = token } else { session, err := app.sessionStore.Get(r, cookieName) if err != nil { // The cookie should still save, even if there's an error. // Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144 log.Error("Session: %v; ignoring", err) } session.Values[cookieUserVal] = resUser.User.Cookie() err = session.Save(r, w) if err != nil { log.Error("Couldn't save session: %v", err) return nil, err } } if reqJSON { return resUser, impart.WriteSuccess(w, resUser, http.StatusCreated) } return resUser, nil } func viewLogout(app *App, w http.ResponseWriter, r *http.Request) error { session, err := app.sessionStore.Get(r, cookieName) if err != nil { return ErrInternalCookieSession } // Ensure user has an email or password set before they go, so they don't // lose access to their account. val := session.Values[cookieUserVal] var u = &User{} var ok bool if u, ok = val.(*User); !ok { log.Error("Error casting user object on logout. Vals: %+v Resetting cookie.", session.Values) err = session.Save(r, w) if err != nil { log.Error("Couldn't save session on logout: %v", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to save cookie session."} } return impart.HTTPError{http.StatusFound, "/"} } u, err = app.db.GetUserByID(u.ID) if err != nil && err != ErrUserNotFound { return impart.HTTPError{http.StatusInternalServerError, "Unable to fetch user information."} } session.Options.MaxAge = -1 err = session.Save(r, w) if err != nil { log.Error("Couldn't save session on logout: %v", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to save cookie session."} } return impart.HTTPError{http.StatusFound, "/"} } func handleAPILogout(app *App, w http.ResponseWriter, r *http.Request) error { accessToken := r.Header.Get("Authorization") if accessToken == "" { return ErrNoAccessToken } t := auth.GetToken(accessToken) if len(t) == 0 { return ErrNoAccessToken } err := app.db.DeleteToken(t) if err != nil { return err } return impart.HTTPError{Status: http.StatusNoContent} } func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error { var earlyError string oneTimeToken := r.FormValue("with") if oneTimeToken != "" { log.Info("Calling login with one-time token.") err := login(app, w, r) if err != nil { log.Info("Received error: %v", err) earlyError = fmt.Sprintf("%s", err) } } session, err := app.sessionStore.Get(r, cookieName) if err != nil { // Ignore this log.Error("Unable to get session; ignoring: %v", err) } p := &struct { page.StaticPage *OAuthButtons To string Message template.HTML Flashes []template.HTML LoginUsername string }{ StaticPage: pageForReq(app, r), OAuthButtons: NewOAuthButtons(app.Config()), To: r.FormValue("to"), Message: template.HTML(""), Flashes: []template.HTML{}, LoginUsername: getTempInfo(app, "login-user", r, w), } if earlyError != "" { p.Flashes = append(p.Flashes, template.HTML(earlyError)) } // Display any error messages flashes, _ := getSessionFlashes(app, w, r, session) for _, flash := range flashes { p.Flashes = append(p.Flashes, template.HTML(flash)) } err = pages["login.tmpl"].ExecuteTemplate(w, "base", p) if err != nil { log.Error("Unable to render login: %v", err) return err } return nil } func webLogin(app *App, w http.ResponseWriter, r *http.Request) error { err := login(app, w, r) if err != nil { username := r.FormValue("alias") // Login request was unsuccessful; save the error in the session and redirect them if err, ok := err.(impart.HTTPError); ok { session, _ := app.sessionStore.Get(r, cookieName) if session != nil { session.AddFlash(err.Message) session.Save(r, w) } if m := actuallyUsernameReg.FindStringSubmatch(err.Message); len(m) > 0 { // Retain fixed username recommendation for the login form username = m[1] } } // Pass along certain information saveTempInfo(app, "login-user", username, r, w) // Retain post-login URL if one was given redirectTo := "/login" postLoginRedirect := r.FormValue("to") if postLoginRedirect != "" { redirectTo += "?to=" + postLoginRedirect } log.Error("Unable to login: %v", err) return impart.HTTPError{http.StatusTemporaryRedirect, redirectTo} } return nil } var loginAttemptUsers = sync.Map{} func login(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) oneTimeToken := r.FormValue("with") verbose := r.FormValue("all") == "true" || r.FormValue("verbose") == "1" || r.FormValue("verbose") == "true" || (reqJSON && oneTimeToken != "") redirectTo := r.FormValue("to") if redirectTo == "" { if app.cfg.App.SingleUser { redirectTo = "/me/new" } else { redirectTo = "/" } } var u *User var err error var signin userCredentials if app.cfg.App.DisablePasswordAuth { err := ErrDisabledPasswordAuth return err } // Log in with one-time token if one is given if oneTimeToken != "" { log.Info("Login: Logging user in via token.") userID := app.db.GetUserID(oneTimeToken) if userID == -1 { log.Error("Login: Got user -1 from token") err := ErrBadAccessToken err.Message = "Expired or invalid login code." return err } log.Info("Login: Found user %d.", userID) u, err = app.db.GetUserByID(userID) if err != nil { log.Error("Unable to fetch user on one-time token login: %v", err) return impart.HTTPError{http.StatusInternalServerError, "There was an error retrieving the user you want."} } log.Info("Login: Got user via token") } else { // Get params if reqJSON { decoder := json.NewDecoder(r.Body) err := decoder.Decode(&signin) if err != nil { log.Error("Couldn't parse signin JSON request: %v\n", err) return ErrBadJSON } } else { err := r.ParseForm() if err != nil { log.Error("Couldn't parse signin form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&signin, r.PostForm) if err != nil { log.Error("Couldn't decode signin form request: %v\n", err) return ErrBadFormData } } log.Info("Login: Attempting login for '%s'", signin.Alias) // Validate required params (all) if signin.Alias == "" { msg := "Parameter `alias` required." if signin.Web { msg = "A username is required." } return impart.HTTPError{http.StatusBadRequest, msg} } if !signin.EmailLogin && signin.Pass == "" { msg := "Parameter `pass` required." if signin.Web { msg = "A password is required." } return impart.HTTPError{http.StatusBadRequest, msg} } // Prevent excessive login attempts on the same account // Skip this check in dev environment if !app.cfg.Server.Dev { now := time.Now() attemptExp, att := loginAttemptUsers.LoadOrStore(signin.Alias, now.Add(loginAttemptExpiration)) if att { if attemptExpTime, ok := attemptExp.(time.Time); ok { if attemptExpTime.After(now) { // This user attempted previously, and the period hasn't expired yet return impart.HTTPError{http.StatusTooManyRequests, "You're doing that too much."} } else { // This user attempted previously, but the time expired; free up space loginAttemptUsers.Delete(signin.Alias) } } else { log.Error("Unable to cast expiration to time") } } } // Retrieve password u, err = app.db.GetUserForAuth(signin.Alias) if err != nil { log.Info("Unable to getUserForAuth on %s: %v", signin.Alias, err) if strings.IndexAny(signin.Alias, "@") > 0 { log.Info("Suggesting: %s", ErrUserNotFoundEmail.Message) return ErrUserNotFoundEmail } return err } // Authenticate if u.Email.String == "" { // User has no email set, so check if they haven't added a password, either, // so we can return a more helpful error message. if hasPass, _ := app.db.IsUserPassSet(u.ID); !hasPass { log.Info("Tried logging in to %s, but no password or email.", signin.Alias) return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."} } } if len(u.HashedPass) == 0 { return impart.HTTPError{http.StatusUnauthorized, "This user never set a password. Perhaps try logging in via OAuth?"} } if !auth.Authenticated(u.HashedPass, []byte(signin.Pass)) { return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."} } } if reqJSON && !signin.Web { var token string if r.Header.Get("User-Agent") == "" { // Get last created token when User-Agent is empty token = app.db.FetchLastAccessToken(u.ID) if token == "" { token, err = app.db.GetAccessToken(u.ID) } } else { token, err = app.db.GetAccessToken(u.ID) } if err != nil { log.Error("Login: Unable to create access token: %v", err) return impart.HTTPError{http.StatusInternalServerError, "Could not create access token. Try re-authenticating."} } resUser := getVerboseAuthUser(app, token, u, verbose) return impart.WriteSuccess(w, resUser, http.StatusOK) } session, err := app.sessionStore.Get(r, cookieName) if err != nil { // The cookie should still save, even if there's an error. log.Error("Login: Session: %v; ignoring", err) } // Remove unwanted data session.Values[cookieUserVal] = u.Cookie() err = session.Save(r, w) if err != nil { log.Error("Login: Couldn't save session: %v", err) // TODO: return error } // Send success if reqJSON { return impart.WriteSuccess(w, &AuthUser{User: u}, http.StatusOK) } log.Info("Login: Redirecting to %s", redirectTo) w.Header().Set("Location", redirectTo) w.WriteHeader(http.StatusFound) return nil } func getVerboseAuthUser(app *App, token string, u *User, verbose bool) *AuthUser { resUser := &AuthUser{ AccessToken: token, User: u, } // Fetch verbose user data if requested if verbose { posts, err := app.db.GetUserPosts(u) if err != nil { log.Error("Login: Unable to get user posts: %v", err) } colls, err := app.db.GetCollections(u, app.cfg.App.Host) if err != nil { log.Error("Login: Unable to get user collections: %v", err) } passIsSet, err := app.db.IsUserPassSet(u.ID) if err != nil { // TODO: correct error meesage log.Error("Login: Unable to get user collections: %v", err) } resUser.Posts = posts resUser.Collections = colls resUser.User.HasPass = passIsSet } return resUser } func viewExportOptions(app *App, u *User, w http.ResponseWriter, r *http.Request) error { // Fetch extra user data p := NewUserPage(app, r, u, "Export", nil) showUserPage(w, "export", p) return nil } func viewExportPosts(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) { var filename string var u = &User{} reqJSON := IsJSON(r) if reqJSON { // Use given Authorization header accessToken := r.Header.Get("Authorization") if accessToken == "" { return nil, filename, ErrNoAccessToken } userID := app.db.GetUserID(accessToken) if userID == -1 { return nil, filename, ErrBadAccessToken } var err error u, err = app.db.GetUserByID(userID) if err != nil { return nil, filename, impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve requested user."} } } else { // Use user cookie session, err := app.sessionStore.Get(r, cookieName) if err != nil { // The cookie should still save, even if there's an error. log.Error("Session: %v; ignoring", err) } val := session.Values[cookieUserVal] var ok bool if u, ok = val.(*User); !ok { return nil, filename, ErrNotLoggedIn } } filename = u.Username + "-posts-" + time.Now().Truncate(time.Second).UTC().Format("200601021504") // Fetch data we're exporting var err error var data []byte posts, err := app.db.GetUserPosts(u) if err != nil { return data, filename, err } // Export as CSV if strings.HasSuffix(r.URL.Path, ".csv") { data = exportPostsCSV(app.cfg.App.Host, u, posts) return data, filename, err } if strings.HasSuffix(r.URL.Path, ".zip") { data = exportPostsZip(u, posts) return data, filename, err } if r.FormValue("pretty") == "1" { data, err = json.MarshalIndent(posts, "", "\t") } else { data, err = json.Marshal(posts) } return data, filename, err } func viewExportFull(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) { var err error filename := "" u := getUserSession(app, r) if u == nil { return nil, filename, ErrNotLoggedIn } filename = u.Username + "-" + time.Now().Truncate(time.Second).UTC().Format("200601021504") exportUser := compileFullExport(app, u) var data []byte if r.FormValue("pretty") == "1" { data, err = json.MarshalIndent(exportUser, "", "\t") } else { data, err = json.Marshal(exportUser) } return data, filename, err } func viewMeAPI(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) uObj := struct { ID int64 `json:"id,omitempty"` Username string `json:"username,omitempty"` }{} var err error if reqJSON { _, uObj.Username, err = app.db.GetUserDataFromToken(r.Header.Get("Authorization")) if err != nil { return err } } else { u := getUserSession(app, r) if u == nil { return impart.WriteSuccess(w, uObj, http.StatusOK) } uObj.Username = u.Username } return impart.WriteSuccess(w, uObj, http.StatusOK) } func viewMyPostsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) if !reqJSON { return ErrBadRequestedType } isAnonPosts := r.FormValue("anonymous") == "1" if isAnonPosts { pageStr := r.FormValue("page") pg, err := strconv.Atoi(pageStr) if err != nil { log.Error("Error parsing page parameter '%s': %s", pageStr, err) pg = 1 } p, err := app.db.GetAnonymousPosts(u, pg) if err != nil { return err } return impart.WriteSuccess(w, p, http.StatusOK) } var err error p := GetPostsCache(u.ID) if p == nil { userPostsCache.Lock() if userPostsCache.users[u.ID].ready == nil { userPostsCache.users[u.ID] = postsCacheItem{ready: make(chan struct{})} userPostsCache.Unlock() p, err = app.db.GetUserPosts(u) if err != nil { return err } CachePosts(u.ID, p) } else { userPostsCache.Unlock() <-userPostsCache.users[u.ID].ready p = GetPostsCache(u.ID) } } return impart.WriteSuccess(w, p, http.StatusOK) } func viewMyCollectionsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) if !reqJSON { return ErrBadRequestedType } p, err := app.db.GetCollections(u, app.cfg.App.Host) if err != nil { return err } return impart.WriteSuccess(w, p, http.StatusOK) } func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) error { p, err := app.db.GetAnonymousPosts(u, 1) if err != nil { log.Error("unable to fetch anon posts: %v", err) } // nil-out AnonymousPosts slice for easy detection in the template if p != nil && len(*p) == 0 { p = nil } f, err := getSessionFlashes(app, w, r, nil) if err != nil { log.Error("unable to fetch flashes: %v", err) } c, err := app.db.GetPublishableCollections(u, app.cfg.App.Host) if err != nil { log.Error("unable to fetch collections: %v", err) } silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { if err == ErrUserNotFound { return err } log.Error("view articles: %v", err) } d := struct { *UserPage AnonymousPosts *[]PublicPost Collections *[]Collection Silenced bool }{ UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f), AnonymousPosts: p, Collections: c, Silenced: silenced, } d.UserPage.SetMessaging(u) w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Expires", "Thu, 04 Oct 1990 20:00:00 GMT") showUserPage(w, "articles", d) return nil } func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request) error { c, err := app.db.GetCollections(u, app.cfg.App.Host) if err != nil { log.Error("unable to fetch collections: %v", err) return fmt.Errorf("No collections") } f, _ := getSessionFlashes(app, w, r, nil) uc, _ := app.db.GetUserCollectionCount(u.ID) // TODO: handle any errors silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { if err == ErrUserNotFound { return err } log.Error("view collections: %v", err) return fmt.Errorf("view collections: %v", err) } d := struct { *UserPage Collections *[]Collection UsedCollections, TotalCollections int NewBlogsDisabled bool Silenced bool }{ UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f), Collections: c, UsedCollections: int(uc), NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc), Silenced: silenced, } d.UserPage.SetMessaging(u) showUserPage(w, "collections", d) return nil } func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) c, err := app.db.GetCollection(vars["collection"]) if err != nil { return err } if c.OwnerID != u.ID { return ErrCollectionNotFound } silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { if err == ErrUserNotFound { return err } log.Error("view edit collection %v", err) return fmt.Errorf("view edit collection: %v", err) } flashes, _ := getSessionFlashes(app, w, r, nil) obj := struct { *UserPage *Collection Silenced bool - config.LettersCfg + config.EmailCfg LetterReplyTo string }{ UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes), Collection: c, Silenced: silenced, - LettersCfg: app.cfg.Letters, + EmailCfg: app.cfg.Email, } obj.UserPage.CollAlias = c.Alias - if obj.LettersCfg.Enabled() { + if obj.EmailCfg.Enabled() { obj.LetterReplyTo = app.db.GetCollectionAttribute(c.ID, collAttrLetterReplyTo) } showUserPage(w, "collection", obj) return nil } func updateSettings(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) var s userSettings var u *User var sess *sessions.Session var err error if reqJSON { accessToken := r.Header.Get("Authorization") if accessToken == "" { return ErrNoAccessToken } u, err = app.db.GetAPIUser(accessToken) if err != nil { return ErrBadAccessToken } decoder := json.NewDecoder(r.Body) err := decoder.Decode(&s) if err != nil { log.Error("Couldn't parse settings JSON request: %v\n", err) return ErrBadJSON } // Prevent all username updates // TODO: support changing username via JSON API request s.Username = "" } else { u, sess = getUserAndSession(app, r) if u == nil { return ErrNotLoggedIn } err := r.ParseForm() if err != nil { log.Error("Couldn't parse settings form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&s, r.PostForm) if err != nil { log.Error("Couldn't decode settings form request: %v\n", err) return ErrBadFormData } } // Do update postUpdateReturn := r.FormValue("return") redirectTo := "/me/settings" if s.IsLogOut { redirectTo += "?logout=1" } else if postUpdateReturn != "" { redirectTo = postUpdateReturn } // Only do updates on values we need if s.Username != "" && s.Username == u.Username { // Username hasn't actually changed; blank it out s.Username = "" } err = app.db.ChangeSettings(app, u, &s) if err != nil { if reqJSON { return err } if err, ok := err.(impart.HTTPError); ok { addSessionFlash(app, w, r, err.Message, nil) } } else { // Successful update. if reqJSON { return impart.WriteSuccess(w, u, http.StatusOK) } if s.IsLogOut { redirectTo = "/me/logout" } else { sess.Values[cookieUserVal] = u.Cookie() addSessionFlash(app, w, r, "Account updated.", nil) } } w.Header().Set("Location", redirectTo) w.WriteHeader(http.StatusFound) return nil } func updatePassphrase(app *App, w http.ResponseWriter, r *http.Request) error { accessToken := r.Header.Get("Authorization") if accessToken == "" { return ErrNoAccessToken } curPass := r.FormValue("current") newPass := r.FormValue("new") // Ensure a new password is given (always required) if newPass == "" { return impart.HTTPError{http.StatusBadRequest, "Provide a new password."} } userID, sudo := app.db.GetUserIDPrivilege(accessToken) if userID == -1 { return ErrBadAccessToken } // Ensure a current password is given if the access token doesn't have sudo // privileges. if !sudo && curPass == "" { return impart.HTTPError{http.StatusBadRequest, "Provide current password."} } // Hash the new password hashedPass, err := auth.HashPass([]byte(newPass)) if err != nil { return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} } // Do update err = app.db.ChangePassphrase(userID, sudo, curPass, hashedPass) if err != nil { return err } return impart.WriteSuccess(w, struct{}{}, http.StatusOK) } func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error { var c *Collection var err error vars := mux.Vars(r) alias := vars["collection"] if alias != "" { c, err = app.db.GetCollection(alias) if err != nil { return err } if c.OwnerID != u.ID { return ErrCollectionNotFound } c.hostName = app.cfg.App.Host } topPosts, err := app.db.GetTopPosts(u, alias, c.hostName) if err != nil { log.Error("Unable to get top posts: %v", err) return err } flashes, _ := getSessionFlashes(app, w, r, nil) titleStats := "" if c != nil { titleStats = c.DisplayTitle() + " " } silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { if err == ErrUserNotFound { return err } log.Error("view stats: %v", err) return err } obj := struct { *UserPage VisitsBlog string Collection *Collection TopPosts *[]PublicPost APFollowers int Silenced bool }{ UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes), VisitsBlog: alias, Collection: c, TopPosts: topPosts, Silenced: silenced, } obj.UserPage.CollAlias = c.Alias if app.cfg.App.Federation { folls, err := app.db.GetAPFollowers(c) if err != nil { return err } obj.APFollowers = len(*folls) } showUserPage(w, "stats", obj) return nil } func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error { fullUser, err := app.db.GetUserByID(u.ID) if err != nil { if err == ErrUserNotFound { return err } log.Error("Unable to get user for settings: %s", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."} } passIsSet, err := app.db.IsUserPassSet(u.ID) if err != nil { log.Error("Unable to get isUserPassSet for settings: %s", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."} } flashes, _ := getSessionFlashes(app, w, r, nil) enableOauthSlack := app.Config().SlackOauth.ClientID != "" enableOauthWriteAs := app.Config().WriteAsOauth.ClientID != "" enableOauthGitLab := app.Config().GitlabOauth.ClientID != "" enableOauthGeneric := app.Config().GenericOauth.ClientID != "" enableOauthGitea := app.Config().GiteaOauth.ClientID != "" oauthAccounts, err := app.db.GetOauthAccounts(r.Context(), u.ID) if err != nil { log.Error("Unable to get oauth accounts for settings: %s", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."} } for idx, oauthAccount := range oauthAccounts { switch oauthAccount.Provider { case "slack": enableOauthSlack = false case "write.as": enableOauthWriteAs = false case "gitlab": enableOauthGitLab = false case "generic": oauthAccounts[idx].DisplayName = app.Config().GenericOauth.DisplayName oauthAccounts[idx].AllowDisconnect = app.Config().GenericOauth.AllowDisconnect enableOauthGeneric = false case "gitea": enableOauthGitea = false } } displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || enableOauthGeneric || enableOauthGitea || len(oauthAccounts) > 0 obj := struct { *UserPage Email string HasPass bool IsLogOut bool Silenced bool CSRFField template.HTML OauthSection bool OauthAccounts []oauthAccountInfo OauthSlack bool OauthWriteAs bool OauthGitLab bool GitLabDisplayName string OauthGeneric bool OauthGenericDisplayName string OauthGitea bool GiteaDisplayName string }{ UserPage: NewUserPage(app, r, u, "Account Settings", flashes), Email: fullUser.EmailClear(app.keys), HasPass: passIsSet, IsLogOut: r.FormValue("logout") == "1", Silenced: fullUser.IsSilenced(), CSRFField: csrf.TemplateField(r), OauthSection: displayOauthSection, OauthAccounts: oauthAccounts, OauthSlack: enableOauthSlack, OauthWriteAs: enableOauthWriteAs, OauthGitLab: enableOauthGitLab, GitLabDisplayName: config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName), OauthGeneric: enableOauthGeneric, OauthGenericDisplayName: config.OrDefaultString(app.Config().GenericOauth.DisplayName, genericOauthDisplayName), OauthGitea: enableOauthGitea, GiteaDisplayName: config.OrDefaultString(app.Config().GiteaOauth.DisplayName, giteaDisplayName), } showUserPage(w, "settings", obj) return nil } func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error { session, err := app.sessionStore.Get(r, "t") if err != nil { return ErrInternalCookieSession } session.Values[key] = val err = session.Save(r, w) if err != nil { log.Error("Couldn't saveTempInfo for key-val (%s:%s): %v", key, val, err) } return err } func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) string { session, err := app.sessionStore.Get(r, "t") if err != nil { return "" } // Get the information var s = "" var ok bool if s, ok = session.Values[key].(string); !ok { return "" } // Delete cookie session.Options.MaxAge = -1 err = session.Save(r, w) if err != nil { log.Error("Couldn't erase temp data for key %s: %v", key, err) } // Return value return s } func handleUserDelete(app *App, u *User, w http.ResponseWriter, r *http.Request) error { if !app.cfg.App.OpenDeletion { return impart.HTTPError{http.StatusForbidden, "Open account deletion is disabled on this instance."} } confirmUsername := r.PostFormValue("confirm-username") if u.Username != confirmUsername { return impart.HTTPError{http.StatusBadRequest, "Confirmation username must match your username exactly."} } // Check for account deletion safeguards in place if u.IsAdmin() { return impart.HTTPError{http.StatusForbidden, "Cannot delete admin."} } err := app.db.DeleteAccount(u.ID) if err != nil { log.Error("user delete account: %v", err) return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not delete account: %v", err)} } // FIXME: This doesn't ever appear to the user, as (I believe) the value is erased when the session cookie is reset _ = addSessionFlash(app, w, r, "Thanks for writing with us! You account was deleted successfully.", nil) return impart.HTTPError{http.StatusFound, "/me/logout"} } func removeOauth(app *App, u *User, w http.ResponseWriter, r *http.Request) error { provider := r.FormValue("provider") clientID := r.FormValue("client_id") remoteUserID := r.FormValue("remote_user_id") err := app.db.RemoveOauth(r.Context(), u.ID, provider, clientID, remoteUserID) if err != nil { return impart.HTTPError{Status: http.StatusInternalServerError, Message: err.Error()} } return impart.HTTPError{Status: http.StatusFound, Message: "/me/settings"} } func prepareUserEmail(input string, emailKey []byte) zero.String { email := zero.NewString("", input != "") if len(input) > 0 { encEmail, err := data.Encrypt(emailKey, input) if err != nil { log.Error("Unable to encrypt email: %s\n", err) } else { email.String = string(encEmail) } } return email } diff --git a/app.go b/app.go index 54a6ccc..42d9891 100644 --- a/app.go +++ b/app.go @@ -1,1003 +1,1003 @@ /* * Copyright © 2018-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "crypto/tls" "database/sql" _ "embed" "fmt" "html/template" "net" "net/http" "net/url" "os" "os/signal" "path/filepath" "regexp" "strings" "syscall" "time" "github.com/gorilla/mux" "github.com/gorilla/schema" "github.com/gorilla/sessions" "github.com/manifoldco/promptui" stripmd "github.com/writeas/go-strip-markdown/v2" "github.com/writeas/impart" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/converter" "github.com/writeas/web-core/log" "golang.org/x/crypto/acme/autocert" "github.com/writefreely/writefreely/author" "github.com/writefreely/writefreely/config" "github.com/writefreely/writefreely/key" "github.com/writefreely/writefreely/migrations" "github.com/writefreely/writefreely/page" ) const ( staticDir = "static" assumedTitleLen = 80 postsPerPage = 10 serverSoftware = "WriteFreely" softwareURL = "https://writefreely.org" ) var ( debugging bool // Software version can be set from git env using -ldflags softwareVer = "0.14.0" // DEPRECATED VARS isSingleUser bool ) // App holds data and configuration for an individual WriteFreely instance. type App struct { router *mux.Router shttp *http.ServeMux db *datastore cfg *config.Config cfgFile string keys *key.Keychain sessionStore sessions.Store formDecoder *schema.Decoder updates *updatesCache timeline *localTimeline } // DB returns the App's datastore func (app *App) DB() *datastore { return app.db } // Router returns the App's router func (app *App) Router() *mux.Router { return app.router } // Config returns the App's current configuration. func (app *App) Config() *config.Config { return app.cfg } // SetConfig updates the App's Config to the given value. func (app *App) SetConfig(cfg *config.Config) { app.cfg = cfg } // SetKeys updates the App's Keychain to the given value. func (app *App) SetKeys(k *key.Keychain) { app.keys = k } func (app *App) SessionStore() sessions.Store { return app.sessionStore } func (app *App) SetSessionStore(s sessions.Store) { app.sessionStore = s } // Apper is the interface for getting data into and out of a WriteFreely // instance (or "App"). // // App returns the App for the current instance. // // LoadConfig reads an app configuration into the App, returning any error // encountered. // // SaveConfig persists the current App configuration. // // LoadKeys reads the App's encryption keys and loads them into its // key.Keychain. type Apper interface { App() *App LoadConfig() error SaveConfig(*config.Config) error LoadKeys() error ReqLog(r *http.Request, status int, timeSince time.Duration) string } // App returns the App func (app *App) App() *App { return app } // LoadConfig loads and parses a config file. func (app *App) LoadConfig() error { log.Info("Loading %s configuration...", app.cfgFile) cfg, err := config.Load(app.cfgFile) if err != nil { log.Error("Unable to load configuration: %v", err) os.Exit(1) return err } app.cfg = cfg return nil } // SaveConfig saves the given Config to disk -- namely, to the App's cfgFile. func (app *App) SaveConfig(c *config.Config) error { return config.Save(c, app.cfgFile) } // LoadKeys reads all needed keys from disk into the App. In order to use the // configured `Server.KeysParentDir`, you must call initKeyPaths(App) before // this. func (app *App) LoadKeys() error { var err error app.keys = &key.Keychain{} if debugging { log.Info(" %s", emailKeyPath) } executable, err := os.Executable() if err != nil { executable = "writefreely" } else { executable = filepath.Base(executable) } app.keys.EmailKey, err = os.ReadFile(emailKeyPath) if err != nil { return err } if debugging { log.Info(" %s", cookieAuthKeyPath) } app.keys.CookieAuthKey, err = os.ReadFile(cookieAuthKeyPath) if err != nil { return err } if debugging { log.Info(" %s", cookieKeyPath) } app.keys.CookieKey, err = os.ReadFile(cookieKeyPath) if err != nil { return err } if debugging { log.Info(" %s", csrfKeyPath) } app.keys.CSRFKey, err = os.ReadFile(csrfKeyPath) if err != nil { if os.IsNotExist(err) { log.Error(`Missing key: %s. Run this command to generate missing keys: %s keys generate `, csrfKeyPath, executable) } return err } return nil } func (app *App) ReqLog(r *http.Request, status int, timeSince time.Duration) string { return fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, timeSince, r.UserAgent()) } // handleViewHome shows page at root path. It checks the configuration and // authentication state to show the correct page. func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error { if app.cfg.App.SingleUser { // Render blog index return handleViewCollection(app, w, r) } // Multi-user instance forceLanding := r.FormValue("landing") == "1" if !forceLanding { // Show correct page based on user auth status and configured landing path u := getUserSession(app, r) if app.cfg.App.Chorus { // This instance is focused on reading, so show Reader on home route if not // private or a private-instance user is logged in. if !app.cfg.App.Private || u != nil { return viewLocalTimeline(app, w, r) } } if u != nil { // User is logged in, so show the Pad return handleViewPad(app, w, r) } if app.cfg.App.Private { return viewLogin(app, w, r) } if land := app.cfg.App.LandingPath(); land != "/" { return impart.HTTPError{http.StatusFound, land} } } return handleViewLanding(app, w, r) } func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error { forceLanding := r.FormValue("landing") == "1" p := struct { page.StaticPage *OAuthButtons Flashes []template.HTML Banner template.HTML Content template.HTML ForcedLanding bool }{ StaticPage: pageForReq(app, r), OAuthButtons: NewOAuthButtons(app.Config()), ForcedLanding: forceLanding, } banner, err := getLandingBanner(app) if err != nil { log.Error("unable to get landing banner: %v", err) return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)} } p.Banner = template.HTML(applyMarkdown([]byte(banner.Content), "", app.cfg)) content, err := getLandingBody(app) if err != nil { log.Error("unable to get landing content: %v", err) return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get content: %v", err)} } p.Content = template.HTML(applyMarkdown([]byte(content.Content), "", app.cfg)) // Get error messages session, err := app.sessionStore.Get(r, cookieName) if err != nil { // Ignore this log.Error("Unable to get session in handleViewHome; ignoring: %v", err) } flashes, _ := getSessionFlashes(app, w, r, session) for _, flash := range flashes { p.Flashes = append(p.Flashes, template.HTML(flash)) } // Show landing page return renderPage(w, "landing.tmpl", p) } func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *template.Template) error { p := struct { page.StaticPage ContentTitle string Content template.HTML PlainContent string Updated string AboutStats *InstanceStats }{ StaticPage: pageForReq(app, r), } if r.URL.Path == "/about" || r.URL.Path == "/contact" || r.URL.Path == "/privacy" { var c *instanceContent var err error if r.URL.Path == "/about" { c, err = getAboutPage(app) // Fetch stats p.AboutStats = &InstanceStats{} p.AboutStats.NumPosts, _ = app.db.GetTotalPosts() p.AboutStats.NumBlogs, _ = app.db.GetTotalCollections() } else if r.URL.Path == "/contact" { c, err = getContactPage(app) if c.Updated.IsZero() { // Page was never set up, so return 404 return ErrPostNotFound } } else { c, err = getPrivacyPage(app) } if err != nil { return err } p.ContentTitle = c.Title.String p.Content = template.HTML(applyMarkdown([]byte(c.Content), "", app.cfg)) p.PlainContent = shortPostDescription(stripmd.Strip(c.Content)) if !c.Updated.IsZero() { p.Updated = c.Updated.Format("January 2, 2006") } } // Serve templated page err := t.ExecuteTemplate(w, "base", p) if err != nil { log.Error("Unable to render page: %v", err) } return nil } func pageForReq(app *App, r *http.Request) page.StaticPage { p := page.StaticPage{ AppCfg: app.cfg.App, Path: r.URL.Path, Version: "v" + softwareVer, } // Use custom style, if file exists if _, err := os.Stat(filepath.Join(staticDir, "local", "custom.css")); err == nil { p.CustomCSS = true } // Add user information, if given var u *User accessToken := r.FormValue("t") if accessToken != "" { userID := app.db.GetUserID(accessToken) if userID != -1 { var err error u, err = app.db.GetUserByID(userID) if err == nil { p.Username = u.Username } } } else { u = getUserSession(app, r) if u != nil { p.Username = u.Username p.IsAdmin = u != nil && u.IsAdmin() p.CanInvite = canUserInvite(app.cfg, p.IsAdmin) } } p.CanViewReader = !app.cfg.App.Private || u != nil return p } var fileRegex = regexp.MustCompile("/([^/]*\\.[^/]*)$") // Initialize loads the app configuration and initializes templates, keys, // session, route handlers, and the database connection. func Initialize(apper Apper, debug bool) (*App, error) { debugging = debug apper.LoadConfig() // Load templates err := InitTemplates(apper.App().Config()) if err != nil { return nil, fmt.Errorf("load templates: %s", err) } // Load keys and set up session initKeyPaths(apper.App()) // TODO: find a better way to do this, since it's unneeded in all Apper implementations err = InitKeys(apper) if err != nil { return nil, fmt.Errorf("init keys: %s", err) } apper.App().InitUpdates() apper.App().InitSession() apper.App().InitDecoder() err = ConnectToDatabase(apper.App()) if err != nil { return nil, fmt.Errorf("connect to DB: %s", err) } initActivityPub(apper.App()) - if apper.App().cfg.Letters.Domain != "" || apper.App().cfg.Letters.MailgunPrivate != "" { - if apper.App().cfg.Letters.Domain == "" { + if apper.App().cfg.Email.Domain != "" || apper.App().cfg.Email.MailgunPrivate != "" { + if apper.App().cfg.Email.Domain == "" { log.Error("[FAILED] Starting publish jobs queue: no [letters]domain config value set.") - } else if apper.App().cfg.Letters.MailgunPrivate == "" { + } else if apper.App().cfg.Email.MailgunPrivate == "" { log.Error("[FAILED] Starting publish jobs queue: no [letters]mailgun_private config value set.") } else { log.Info("Starting publish jobs queue...") go startPublishJobsQueue(apper.App()) } } // Handle local timeline, if enabled if apper.App().cfg.App.LocalTimeline { log.Info("Initializing local timeline...") initLocalTimeline(apper.App()) } return apper.App(), nil } func Serve(app *App, r *mux.Router) { log.Info("Going to serve...") isSingleUser = app.cfg.App.SingleUser app.cfg.Server.Dev = debugging // Handle shutdown c := make(chan os.Signal, 2) signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { <-c log.Info("Shutting down...") shutdown(app) log.Info("Done.") os.Exit(0) }() // Start gopher server if app.cfg.Server.GopherPort > 0 && !app.cfg.App.Private { go initGopher(app) } // Start web application server var bindAddress = app.cfg.Server.Bind if bindAddress == "" { bindAddress = "localhost" } var err error if app.cfg.IsSecureStandalone() { if app.cfg.Server.Autocert { m := &autocert.Manager{ Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(app.cfg.Server.TLSCertPath), } host, err := url.Parse(app.cfg.App.Host) if err != nil { log.Error("[WARNING] Unable to parse configured host! %s", err) log.Error(`[WARNING] ALL hosts are allowed, which can open you to an attack where clients connect to a server by IP address and pretend to be asking for an incorrect host name, and cause you to reach the CA's rate limit for certificate requests. We recommend supplying a valid host name.`) log.Info("Using autocert on ANY host") } else { log.Info("Using autocert on host %s", host.Host) m.HostPolicy = autocert.HostWhitelist(host.Host) } s := &http.Server{ Addr: ":https", Handler: r, TLSConfig: &tls.Config{ GetCertificate: m.GetCertificate, }, } s.SetKeepAlivesEnabled(false) go func() { log.Info("Serving redirects on http://%s:80", bindAddress) err = http.ListenAndServe(":80", m.HTTPHandler(nil)) log.Error("Unable to start redirect server: %v", err) }() log.Info("Serving on https://%s:443", bindAddress) log.Info("---") err = s.ListenAndServeTLS("", "") } else { go func() { log.Info("Serving redirects on http://%s:80", bindAddress) err = http.ListenAndServe(fmt.Sprintf("%s:80", bindAddress), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, app.cfg.App.Host, http.StatusMovedPermanently) })) log.Error("Unable to start redirect server: %v", err) }() log.Info("Serving on https://%s:443", bindAddress) log.Info("Using manual certificates") log.Info("---") err = http.ListenAndServeTLS(fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, r) } } else { network := "tcp" protocol := "http" if strings.HasPrefix(bindAddress, "/") { network = "unix" protocol = "http+unix" // old sockets will remain after server closes; // we need to delete them in order to open new ones err = os.Remove(bindAddress) if err != nil && !os.IsNotExist(err) { log.Error("%s already exists but could not be removed: %v", bindAddress, err) os.Exit(1) } } else { bindAddress = fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port) } log.Info("Serving on %s://%s", protocol, bindAddress) log.Info("---") listener, err := net.Listen(network, bindAddress) if err != nil { log.Error("Could not bind to address: %v", err) os.Exit(1) } if network == "unix" { err = os.Chmod(bindAddress, 0o666) if err != nil { log.Error("Could not update socket permissions: %v", err) os.Exit(1) } } defer listener.Close() err = http.Serve(listener, r) } if err != nil { log.Error("Unable to start: %v", err) os.Exit(1) } } func (app *App) InitDecoder() { // TODO: do this at the package level, instead of the App level // Initialize modules app.formDecoder = schema.NewDecoder() app.formDecoder.RegisterConverter(converter.NullJSONString{}, converter.ConvertJSONNullString) app.formDecoder.RegisterConverter(converter.NullJSONBool{}, converter.ConvertJSONNullBool) app.formDecoder.RegisterConverter(sql.NullString{}, converter.ConvertSQLNullString) app.formDecoder.RegisterConverter(sql.NullBool{}, converter.ConvertSQLNullBool) app.formDecoder.RegisterConverter(sql.NullInt64{}, converter.ConvertSQLNullInt64) app.formDecoder.RegisterConverter(sql.NullFloat64{}, converter.ConvertSQLNullFloat64) } // ConnectToDatabase validates and connects to the configured database, then // tests the connection. func ConnectToDatabase(app *App) error { // Check database configuration if app.cfg.Database.Type == driverMySQL && app.cfg.Database.User == "" { return fmt.Errorf("Database user not set.") } if app.cfg.Database.Host == "" { app.cfg.Database.Host = "localhost" } if app.cfg.Database.Database == "" { app.cfg.Database.Database = "writefreely" } // TODO: check err connectToDatabase(app) // Test database connection err := app.db.Ping() if err != nil { return fmt.Errorf("Database ping failed: %s", err) } return nil } // FormatVersion constructs the version string for the application func FormatVersion() string { return serverSoftware + " " + softwareVer } // OutputVersion prints out the version of the application. func OutputVersion() { fmt.Println(FormatVersion()) } // NewApp creates a new app instance. func NewApp(cfgFile string) *App { return &App{ cfgFile: cfgFile, } } // CreateConfig creates a default configuration and saves it to the app's cfgFile. func CreateConfig(app *App) error { log.Info("Creating configuration...") c := config.New() log.Info("Saving configuration %s...", app.cfgFile) err := config.Save(c, app.cfgFile) if err != nil { return fmt.Errorf("Unable to save configuration: %v", err) } return nil } // DoConfig runs the interactive configuration process. func DoConfig(app *App, configSections string) { if configSections == "" { configSections = "server db app" } // let's check there aren't any garbage in the list configSectionsArray := strings.Split(configSections, " ") for _, element := range configSectionsArray { if element != "server" && element != "db" && element != "app" { log.Error("Invalid argument to --sections. Valid arguments are only \"server\", \"db\" and \"app\"") os.Exit(1) } } d, err := config.Configure(app.cfgFile, configSections) if err != nil { log.Error("Unable to configure: %v", err) os.Exit(1) } app.cfg = d.Config connectToDatabase(app) defer shutdown(app) if !app.db.DatabaseInitialized() { err = adminInitDatabase(app) if err != nil { log.Error(err.Error()) os.Exit(1) } } else { log.Info("Database already initialized.") } if d.User != nil { u := &User{ Username: d.User.Username, HashedPass: d.User.HashedPass, Created: time.Now().Truncate(time.Second).UTC(), } // Create blog log.Info("Creating user %s...\n", u.Username) err = app.db.CreateUser(app.cfg, u, app.cfg.App.SiteName, "") if err != nil { log.Error("Unable to create user: %s", err) os.Exit(1) } log.Info("Done!") } os.Exit(0) } // GenerateKeyFiles creates app encryption keys and saves them into the configured KeysParentDir. func GenerateKeyFiles(app *App) error { // Read keys path from config app.LoadConfig() // Create keys dir if it doesn't exist yet fullKeysDir := filepath.Join(app.cfg.Server.KeysParentDir, keysDir) if _, err := os.Stat(fullKeysDir); os.IsNotExist(err) { err = os.Mkdir(fullKeysDir, 0700) if err != nil { return err } } // Generate keys initKeyPaths(app) // TODO: use something like https://github.com/hashicorp/go-multierror to return errors var keyErrs error err := generateKey(emailKeyPath) if err != nil { keyErrs = err } err = generateKey(cookieAuthKeyPath) if err != nil { keyErrs = err } err = generateKey(cookieKeyPath) if err != nil { keyErrs = err } err = generateKey(csrfKeyPath) if err != nil { keyErrs = err } return keyErrs } // CreateSchema creates all database tables needed for the application. func CreateSchema(apper Apper) error { apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) err := adminInitDatabase(apper.App()) if err != nil { return err } return nil } // Migrate runs all necessary database migrations. func Migrate(apper Apper) error { apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) err := migrations.Migrate(migrations.NewDatastore(apper.App().db.DB, apper.App().db.driverName)) if err != nil { return fmt.Errorf("migrate: %s", err) } return nil } // ResetPassword runs the interactive password reset process. func ResetPassword(apper Apper, username string) error { // Connect to the database apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) // Fetch user u, err := apper.App().db.GetUserForAuth(username) if err != nil { log.Error("Get user: %s", err) os.Exit(1) } // Prompt for new password prompt := promptui.Prompt{ Templates: &promptui.PromptTemplates{ Success: "{{ . | bold | faint }}: ", }, Label: "New password", Mask: '*', } newPass, err := prompt.Run() if err != nil { log.Error("%s", err) os.Exit(1) } // Do the update log.Info("Updating...") err = adminResetPassword(apper.App(), u, newPass) if err != nil { log.Error("%s", err) os.Exit(1) } log.Info("Success.") return nil } // DoDeleteAccount runs the confirmation and account delete process. func DoDeleteAccount(apper Apper, username string) error { // Connect to the database apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) // check user exists u, err := apper.App().db.GetUserForAuth(username) if err != nil { log.Error("%s", err) os.Exit(1) } userID := u.ID // do not delete the admin account // TODO: check for other admins and skip? if u.IsAdmin() { log.Error("Can not delete admin account") os.Exit(1) } // confirm deletion, w/ w/out posts prompt := promptui.Prompt{ Templates: &promptui.PromptTemplates{ Success: "{{ . | bold | faint }}: ", }, Label: fmt.Sprintf("Really delete user : %s", username), IsConfirm: true, } _, err = prompt.Run() if err != nil { log.Info("Aborted...") os.Exit(0) } log.Info("Deleting...") err = apper.App().db.DeleteAccount(userID) if err != nil { log.Error("%s", err) os.Exit(1) } log.Info("Success.") return nil } func connectToDatabase(app *App) { log.Info("Connecting to %s database...", app.cfg.Database.Type) var db *sql.DB var err error if app.cfg.Database.Type == driverMySQL { db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s&tls=%t", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String()), app.cfg.Database.TLS)) db.SetMaxOpenConns(50) } else if app.cfg.Database.Type == driverSQLite { if !SQLiteEnabled { log.Error("Invalid database type '%s'. Binary wasn't compiled with SQLite3 support.", app.cfg.Database.Type) os.Exit(1) } if app.cfg.Database.FileName == "" { log.Error("SQLite database filename value in config.ini is empty.") os.Exit(1) } db, err = sql.Open("sqlite3_with_regex", app.cfg.Database.FileName+"?parseTime=true&cached=shared") db.SetMaxOpenConns(2) } else { log.Error("Invalid database type '%s'. Only 'mysql' and 'sqlite3' are supported right now.", app.cfg.Database.Type) os.Exit(1) } if err != nil { log.Error("%s", err) os.Exit(1) } app.db = &datastore{db, app.cfg.Database.Type} } func shutdown(app *App) { log.Info("Closing database connection...") app.db.Close() if strings.HasPrefix(app.cfg.Server.Bind, "/") { // Clean up socket log.Info("Removing socket file...") err := os.Remove(app.cfg.Server.Bind) if err != nil { log.Error("Unable to remove socket: %s", err) os.Exit(1) } log.Info("Success.") } } // CreateUser creates a new admin or normal user from the given credentials. func CreateUser(apper Apper, username, password string, isAdmin bool) error { // Create an admin user with --create-admin apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) // Ensure an admin / first user doesn't already exist firstUser, _ := apper.App().db.GetUserByID(1) if isAdmin { // Abort if trying to create admin user, but one already exists if firstUser != nil { return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely --create-user", firstUser.Username) } } else { // Abort if trying to create regular user, but no admin exists yet if firstUser == nil { return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely --create-admin") } } // Create the user // Normalize and validate username desiredUsername := username username = getSlug(username, "") usernameDesc := username if username != desiredUsername { usernameDesc += " (originally: " + desiredUsername + ")" } if !author.IsValidUsername(apper.App().cfg, username) { return fmt.Errorf("Username %s is invalid, reserved, or shorter than configured minimum length (%d characters).", usernameDesc, apper.App().cfg.App.MinUsernameLen) } // Hash the password hashedPass, err := auth.HashPass([]byte(password)) if err != nil { return fmt.Errorf("Unable to hash password: %v", err) } u := &User{ Username: username, HashedPass: hashedPass, Created: time.Now().Truncate(time.Second).UTC(), } userType := "user" if isAdmin { userType = "admin" } log.Info("Creating %s %s...", userType, usernameDesc) err = apper.App().db.CreateUser(apper.App().Config(), u, desiredUsername, "") if err != nil { return fmt.Errorf("Unable to create user: %s", err) } log.Info("Done!") return nil } //go:embed schema.sql var schemaSql string //go:embed sqlite.sql var sqliteSql string func adminInitDatabase(app *App) error { var schema string if app.cfg.Database.Type == driverSQLite { schema = sqliteSql } else { schema = schemaSql } tblReg := regexp.MustCompile("CREATE TABLE (IF NOT EXISTS )?`([a-z_]+)`") queries := strings.Split(string(schema), ";\n") for _, q := range queries { if strings.TrimSpace(q) == "" { continue } parts := tblReg.FindStringSubmatch(q) if len(parts) >= 3 { log.Info("Creating table %s...", parts[2]) } else { log.Info("Creating table ??? (Weird query) No match in: %v", parts) } _, err := app.db.Exec(q) if err != nil { log.Error("%s", err) } else { log.Info("Created.") } } // Set up migrations table log.Info("Initializing appmigrations table...") err := migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName)) if err != nil { return fmt.Errorf("Unable to set initial migrations: %v", err) } log.Info("Running migrations...") err = migrations.Migrate(migrations.NewDatastore(app.db.DB, app.db.driverName)) if err != nil { return fmt.Errorf("migrate: %s", err) } log.Info("Done.") return nil } // ServerUserAgent returns a User-Agent string to use in external requests. The // hostName parameter may be left empty. func ServerUserAgent(hostName string) string { hostUAStr := "" if hostName != "" { hostUAStr = "; +" + hostName } return "Go (" + serverSoftware + "/" + softwareVer + hostUAStr + ")" } diff --git a/config/config.go b/config/config.go index 36bbc82..1afd5f3 100644 --- a/config/config.go +++ b/config/config.go @@ -1,305 +1,305 @@ /* * Copyright © 2018-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ // Package config holds and assists in the configuration of a writefreely instance. package config import ( "net/url" "strings" "github.com/go-ini/ini" "github.com/writeas/web-core/log" "golang.org/x/net/idna" ) const ( // FileName is the default configuration file name FileName = "config.ini" UserNormal UserType = "user" UserAdmin = "admin" ) type ( UserType string // ServerCfg holds values that affect how the HTTP server runs ServerCfg struct { HiddenHost string `ini:"hidden_host"` Port int `ini:"port"` Bind string `ini:"bind"` TLSCertPath string `ini:"tls_cert_path"` TLSKeyPath string `ini:"tls_key_path"` Autocert bool `ini:"autocert"` TemplatesParentDir string `ini:"templates_parent_dir"` StaticParentDir string `ini:"static_parent_dir"` PagesParentDir string `ini:"pages_parent_dir"` KeysParentDir string `ini:"keys_parent_dir"` HashSeed string `ini:"hash_seed"` GopherPort int `ini:"gopher_port"` Dev bool `ini:"-"` } // DatabaseCfg holds values that determine how the application connects to a datastore DatabaseCfg struct { Type string `ini:"type"` FileName string `ini:"filename"` User string `ini:"username"` Password string `ini:"password"` Database string `ini:"database"` Host string `ini:"host"` Port int `ini:"port"` TLS bool `ini:"tls"` } WriteAsOauthCfg struct { ClientID string `ini:"client_id"` ClientSecret string `ini:"client_secret"` AuthLocation string `ini:"auth_location"` TokenLocation string `ini:"token_location"` InspectLocation string `ini:"inspect_location"` CallbackProxy string `ini:"callback_proxy"` CallbackProxyAPI string `ini:"callback_proxy_api"` } GitlabOauthCfg struct { ClientID string `ini:"client_id"` ClientSecret string `ini:"client_secret"` Host string `ini:"host"` DisplayName string `ini:"display_name"` CallbackProxy string `ini:"callback_proxy"` CallbackProxyAPI string `ini:"callback_proxy_api"` } GiteaOauthCfg struct { ClientID string `ini:"client_id"` ClientSecret string `ini:"client_secret"` Host string `ini:"host"` DisplayName string `ini:"display_name"` CallbackProxy string `ini:"callback_proxy"` CallbackProxyAPI string `ini:"callback_proxy_api"` } SlackOauthCfg struct { ClientID string `ini:"client_id"` ClientSecret string `ini:"client_secret"` TeamID string `ini:"team_id"` CallbackProxy string `ini:"callback_proxy"` CallbackProxyAPI string `ini:"callback_proxy_api"` } GenericOauthCfg struct { ClientID string `ini:"client_id"` ClientSecret string `ini:"client_secret"` Host string `ini:"host"` DisplayName string `ini:"display_name"` CallbackProxy string `ini:"callback_proxy"` CallbackProxyAPI string `ini:"callback_proxy_api"` TokenEndpoint string `ini:"token_endpoint"` InspectEndpoint string `ini:"inspect_endpoint"` AuthEndpoint string `ini:"auth_endpoint"` Scope string `ini:"scope"` AllowDisconnect bool `ini:"allow_disconnect"` MapUserID string `ini:"map_user_id"` MapUsername string `ini:"map_username"` MapDisplayName string `ini:"map_display_name"` MapEmail string `ini:"map_email"` } // AppCfg holds values that affect how the application functions AppCfg struct { SiteName string `ini:"site_name"` SiteDesc string `ini:"site_description"` Host string `ini:"host"` // Site appearance Theme string `ini:"theme"` Editor string `ini:"editor"` JSDisabled bool `ini:"disable_js"` WebFonts bool `ini:"webfonts"` Landing string `ini:"landing"` SimpleNav bool `ini:"simple_nav"` WFModesty bool `ini:"wf_modesty"` // Site functionality Chorus bool `ini:"chorus"` Forest bool `ini:"forest"` // The admin cares about the forest, not the trees. Hide unnecessary technical info. DisableDrafts bool `ini:"disable_drafts"` // Users SingleUser bool `ini:"single_user"` OpenRegistration bool `ini:"open_registration"` OpenDeletion bool `ini:"open_deletion"` MinUsernameLen int `ini:"min_username_len"` MaxBlogs int `ini:"max_blogs"` // Options for public instances // Federation Federation bool `ini:"federation"` PublicStats bool `ini:"public_stats"` Monetization bool `ini:"monetization"` NotesOnly bool `ini:"notes_only"` // Access Private bool `ini:"private"` // Additional functions LocalTimeline bool `ini:"local_timeline"` UserInvites string `ini:"user_invites"` // Defaults DefaultVisibility string `ini:"default_visibility"` // Check for Updates UpdateChecks bool `ini:"update_checks"` // Disable password authentication if use only Oauth DisablePasswordAuth bool `ini:"disable_password_auth"` } - LettersCfg struct { + EmailCfg struct { Domain string `ini:"domain"` MailgunPrivate string `ini:"mailgun_private"` } // Config holds the complete configuration for running a writefreely instance Config struct { Server ServerCfg `ini:"server"` Database DatabaseCfg `ini:"database"` App AppCfg `ini:"app"` - Letters LettersCfg `ini:"letters"` + Email EmailCfg `ini:"email"` SlackOauth SlackOauthCfg `ini:"oauth.slack"` WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"` GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"` GiteaOauth GiteaOauthCfg `ini:"oauth.gitea"` GenericOauth GenericOauthCfg `ini:"oauth.generic"` } ) // New creates a new Config with sane defaults func New() *Config { c := &Config{ Server: ServerCfg{ Port: 8080, Bind: "localhost", /* IPV6 support when not using localhost? */ }, App: AppCfg{ Host: "http://localhost:8080", Theme: "write", WebFonts: true, SingleUser: true, MinUsernameLen: 3, MaxBlogs: 1, Federation: true, PublicStats: true, }, } c.UseMySQL(true) return c } // UseMySQL resets the Config's Database to use default values for a MySQL setup. func (cfg *Config) UseMySQL(fresh bool) { cfg.Database.Type = "mysql" if fresh { cfg.Database.Host = "localhost" cfg.Database.Port = 3306 } } // UseSQLite resets the Config's Database to use default values for a SQLite setup. func (cfg *Config) UseSQLite(fresh bool) { cfg.Database.Type = "sqlite3" if fresh { cfg.Database.FileName = "writefreely.db" } } // IsSecureStandalone returns whether or not the application is running as a // standalone server with TLS enabled. func (cfg *Config) IsSecureStandalone() bool { return cfg.Server.Port == 443 && cfg.Server.TLSCertPath != "" && cfg.Server.TLSKeyPath != "" } func (ac *AppCfg) LandingPath() string { if !strings.HasPrefix(ac.Landing, "/") { return "/" + ac.Landing } return ac.Landing } -func (lc LettersCfg) Enabled() bool { +func (lc EmailCfg) Enabled() bool { return lc.Domain != "" && lc.MailgunPrivate != "" } func (ac AppCfg) SignupPath() string { if !ac.OpenRegistration { return "" } if ac.Chorus || ac.Private || (ac.Landing != "" && ac.Landing != "/") { return "/signup" } return "/" } // Load reads the given configuration file, then parses and returns it as a Config. func Load(fname string) (*Config, error) { if fname == "" { fname = FileName } cfg, err := ini.Load(fname) if err != nil { return nil, err } // Parse INI file uc := &Config{} err = cfg.MapTo(uc) if err != nil { return nil, err } // Do any transformations u, err := url.Parse(uc.App.Host) if err != nil { return nil, err } d, err := idna.ToASCII(u.Hostname()) if err != nil { log.Error("idna.ToASCII for %s: %s", u.Hostname(), err) return nil, err } uc.App.Host = u.Scheme + "://" + d if u.Port() != "" { uc.App.Host += ":" + u.Port() } return uc, nil } // Save writes the given Config to the given file. func Save(uc *Config, fname string) error { cfg := ini.Empty() err := ini.ReflectFrom(cfg, uc) if err != nil { return err } if fname == "" { fname = FileName } return cfg.SaveTo(fname) } diff --git a/email.go b/email.go index d97ab15..551dd97 100644 --- a/email.go +++ b/email.go @@ -1,468 +1,468 @@ /* * Copyright © 2019-2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "database/sql" "encoding/json" "fmt" "html/template" "net/http" "strings" "time" "github.com/aymerick/douceur/inliner" "github.com/gorilla/mux" "github.com/mailgun/mailgun-go" stripmd "github.com/writeas/go-strip-markdown/v2" "github.com/writeas/impart" "github.com/writeas/web-core/data" "github.com/writeas/web-core/log" "github.com/writefreely/writefreely/key" "github.com/writefreely/writefreely/spam" ) const ( emailSendDelay = 15 ) type ( SubmittedSubscription struct { CollAlias string UserID int64 Email string `schema:"email" json:"email"` Web bool `schema:"web" json:"web"` Slug string `schema:"slug" json:"slug"` From string `schema:"from" json:"from"` } EmailSubscriber struct { ID string CollID int64 UserID sql.NullInt64 Email sql.NullString Subscribed time.Time Token string Confirmed bool AllowExport bool acctEmail sql.NullString } ) func (es *EmailSubscriber) FinalEmail(keys *key.Keychain) string { if !es.UserID.Valid || es.Email.Valid { return es.Email.String } decEmail, err := data.Decrypt(keys.EmailKey, []byte(es.acctEmail.String)) if err != nil { log.Error("Error decrypting user email: %v", err) return "" } return string(decEmail) } func (es *EmailSubscriber) SubscribedFriendly() string { return es.Subscribed.Format("January 2, 2006") } func handleCreateEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) vars := mux.Vars(r) var err error ss := SubmittedSubscription{ CollAlias: vars["alias"], } u := getUserSession(app, r) if u != nil { ss.UserID = u.ID } if reqJSON { // Decode JSON request decoder := json.NewDecoder(r.Body) err = decoder.Decode(&ss) if err != nil { log.Error("Couldn't parse new subscription JSON request: %v\n", err) return ErrBadJSON } } else { err = r.ParseForm() if err != nil { log.Error("Couldn't parse new subscription form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&ss, r.PostForm) if err != nil { log.Error("Continuing, but error decoding new subscription form request: %v\n", err) //return ErrBadFormData } } c, err := app.db.GetCollection(ss.CollAlias) if err != nil { log.Error("getCollection: %s", err) return err } c.hostName = app.cfg.App.Host from := c.CanonicalURL() isAuthorBanned, err := app.db.IsUserSilenced(c.OwnerID) if isAuthorBanned { log.Info("Author is silenced, so subscription is blocked.") return impart.HTTPError{http.StatusFound, from} } if ss.Web { if u != nil && u.ID == c.OwnerID { from = "/" + c.Alias + "/" } from += ss.Slug } if r.FormValue(spam.HoneypotFieldName()) != "" || r.FormValue("fake_password") != "" { log.Info("Honeypot field was filled out! Not subscribing.") return impart.HTTPError{http.StatusFound, from} } if ss.Email == "" && ss.UserID < 1 { log.Info("No subscriber data. Not subscribing.") return impart.HTTPError{http.StatusFound, from} } // Do email validation // TODO: move this to an AJAX call before submitting email address, so we can immediately show errors to user /* err := validate(ss.Email) if err != nil { addSessionFlash(w, r, err.Error(), nil) return impart.HTTPError{http.StatusFound, from} } */ confirmed := app.db.IsSubscriberConfirmed(ss.Email) es, err := app.db.AddEmailSubscription(c.ID, ss.UserID, ss.Email, confirmed) if err != nil { log.Error("addEmailSubscription: %s", err) return err } // Send confirmation email if needed if !confirmed { sendSubConfirmEmail(app, c, ss.Email, es.ID, es.Token) } if ss.Web { session, err := app.sessionStore.Get(r, userEmailCookieName) if err != nil { // The cookie should still save, even if there's an error. // Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144 log.Error("Getting user email cookie: %v; ignoring", err) } if confirmed { addSessionFlash(app, w, r, "Subscribed. You'll now receive future blog posts via email.", nil) } else { addSessionFlash(app, w, r, "Please check your email and click the confirmation link to subscribe.", nil) } session.Values[userEmailCookieVal] = ss.Email err = session.Save(r, w) if err != nil { log.Error("save email cookie: %s", err) return err } return impart.HTTPError{http.StatusFound, from} } return impart.WriteSuccess(w, "", http.StatusAccepted) } func handleDeleteEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { alias := collectionAliasFromReq(r) vars := mux.Vars(r) subID := vars["subscriber"] email := r.FormValue("email") token := r.FormValue("t") slug := r.FormValue("slug") isWeb := r.Method == "GET" // Display collection if this is a collection var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) } if err != nil { log.Error("Get collection: %s", err) return err } from := c.CanonicalURL() if subID != "" { // User unsubscribing via email, so assume action is taken by either current // user or not current user, and only use the request's information to // satisfy this unsubscribe, i.e. subscriberID and token. err = app.db.DeleteEmailSubscriber(subID, token) } else { // User unsubscribing through the web app, so assume action is taken by // currently-auth'd user. var userID int64 u := getUserSession(app, r) if u != nil { // User is logged in userID = u.ID if userID == c.OwnerID { from = "/" + c.Alias + "/" } } if email == "" && userID <= 0 { // Get email address from saved cookie session, err := app.sessionStore.Get(r, userEmailCookieName) if err != nil { log.Error("Unable to get email cookie: %s", err) } else { email = session.Values[userEmailCookieVal].(string) } } if email == "" && userID <= 0 { err = fmt.Errorf("No subscriber given.") log.Error("Not deleting subscription: %s", err) return err } err = app.db.DeleteEmailSubscriberByUser(email, userID, c.ID) } if err != nil { log.Error("Unable to delete subscriber: %v", err) return err } if isWeb { from += slug addSessionFlash(app, w, r, "Unsubscribed. You will no longer receive these blog posts via email.", nil) return impart.HTTPError{http.StatusFound, from} } return impart.WriteSuccess(w, "", http.StatusAccepted) } func handleConfirmEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { alias := collectionAliasFromReq(r) subID := mux.Vars(r)["subscriber"] token := r.FormValue("t") var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) } if err != nil { log.Error("Get collection: %s", err) return err } from := c.CanonicalURL() err = app.db.UpdateSubscriberConfirmed(subID, token) if err != nil { addSessionFlash(app, w, r, err.Error(), nil) return impart.HTTPError{http.StatusFound, from} } addSessionFlash(app, w, r, "Confirmed! Thanks. Now you'll receive future blog posts via email.", nil) return impart.HTTPError{http.StatusFound, from} } func emailPost(app *App, p *PublicPost, collID int64) error { p.augmentContent() // Do some shortcode replacement. // Since the user is receiving this email, we can assume they're subscribed via email. p.Content = strings.Replace(p.Content, "", `

You're subscribed to email updates.

`, -1) if p.HTMLContent == template.HTML("") { p.formatContent(app.cfg, false, false) } p.augmentReadingDestination() title := p.Title.String if title != "" { title = p.Title.String + "\n\n" } plainMsg := title + "A new post from " + p.CanonicalURL(app.cfg.App.Host) + "\n\n" + stripmd.Strip(p.Content) plainMsg += ` --------------------------------------------------------------------------------- Originally published on ` + p.Collection.DisplayTitle() + ` (` + p.Collection.CanonicalURL() + `), a blog you subscribe to. Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%` - gun := mailgun.NewMailgun(app.cfg.Letters.Domain, app.cfg.Letters.MailgunPrivate) - m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Letters.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg) + gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate) + m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg) replyTo := app.db.GetCollectionAttribute(collID, collAttrLetterReplyTo) if replyTo != "" { m.SetReplyTo(replyTo) } subs, err := app.db.GetEmailSubscribers(collID, true) if err != nil { log.Error("Unable to get email subscribers: %v", err) return err } if len(subs) == 0 { return nil } if title != "" { title = string(`

` + p.FormattedDisplayTitle() + `

`) } m.AddTag("New post") fontFam := "Lora, Palatino, Baskerville, serif" if p.IsSans() { fontFam = `"Open Sans", Tahoma, Arial, sans-serif` } else if p.IsMonospace() { fontFam = `Hack, consolas, Menlo-Regular, Menlo, Monaco, monospace, monospace` } // TODO: move this to a templated file and LESS-generated stylesheet fullHTML := `
` + title + `

From ` + p.DisplayCanonicalURL() + `

` + string(p.HTMLContent) + `

` // inline CSS html, err := inliner.Inline(fullHTML) if err != nil { log.Error("Unable to inline email HTML: %v", err) return err } m.SetHtml(html) log.Info("[email] Adding %d recipient(s)", len(subs)) for _, s := range subs { e := s.FinalEmail(app.keys) log.Info("[email] Adding %s", e) err = m.AddRecipientAndVariables(e, map[string]interface{}{ "id": s.ID, "to": e, "token": s.Token, }) if err != nil { log.Error("Unable to add receipient %s: %s", e, err) } } res, _, err := gun.Send(m) log.Info("[email] Send result: %s", res) if err != nil { log.Error("Unable to send post email: %v", err) return err } return nil } func sendSubConfirmEmail(app *App, c *Collection, email, subID, token string) error { if email == "" { return fmt.Errorf("You must supply an email to verify.") } // Send email - gun := mailgun.NewMailgun(app.cfg.Letters.Domain, app.cfg.Letters.MailgunPrivate) + gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate) plainMsg := "Confirm your subscription to " + c.DisplayTitle() + ` (` + c.CanonicalURL() + `) to start receiving future posts. Simply click the following link (or copy and paste it into your browser): ` + c.CanonicalURL() + "email/confirm/" + subID + "?t=" + token + ` If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.` - m := mailgun.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Letters.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email)) + m := mailgun.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Email.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email)) m.AddTag("Email Verification") m.SetHtml(`

Confirm your subscription to ` + c.DisplayTitle() + ` to start receiving future posts:

Subscribe to ` + c.DisplayTitle() + `

If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.

`) gun.Send(m) return nil } diff --git a/posts.go b/posts.go index 6b94cf8..fc8eddc 100644 --- a/posts.go +++ b/posts.go @@ -1,1685 +1,1685 @@ /* * Copyright © 2018-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "database/sql" "encoding/json" "fmt" "github.com/writefreely/writefreely/spam" "html/template" "net/http" "net/url" "regexp" "strings" "time" "github.com/gorilla/mux" "github.com/guregu/null" "github.com/guregu/null/zero" "github.com/kylemcc/twitter-text-go/extract" "github.com/microcosm-cc/bluemonday" stripmd "github.com/writeas/go-strip-markdown/v2" "github.com/writeas/impart" "github.com/writeas/monday" "github.com/writeas/slug" "github.com/writeas/web-core/activitystreams" "github.com/writeas/web-core/bots" "github.com/writeas/web-core/converter" "github.com/writeas/web-core/i18n" "github.com/writeas/web-core/log" "github.com/writeas/web-core/tags" "github.com/writefreely/writefreely/page" "github.com/writefreely/writefreely/parse" ) const ( // Post ID length bounds minIDLen = 10 maxIDLen = 10 userPostIDLen = 10 postIDLen = 10 postMetaDateFormat = "2006-01-02 15:04:05" shortCodePaid = "" ) type ( AnonymousPost struct { ID string Content string HTMLContent template.HTML Font string Language string Direction string Title string GenTitle string Description string Author string Views int64 Images []string IsPlainText bool IsCode bool IsLinkable bool } AuthenticatedPost struct { ID string `json:"id" schema:"id"` Web bool `json:"web" schema:"web"` *SubmittedPost } // SubmittedPost represents a post supplied by a client for publishing or // updating. Since Title and Content can be updated to "", they are // pointers that can be easily tested to detect changes. SubmittedPost struct { Slug *string `json:"slug" schema:"slug"` Title *string `json:"title" schema:"title"` Content *string `json:"body" schema:"body"` Font string `json:"font" schema:"font"` IsRTL converter.NullJSONBool `json:"rtl" schema:"rtl"` Language converter.NullJSONString `json:"lang" schema:"lang"` Created *string `json:"created" schema:"created"` } // Post represents a post as found in the database. Post struct { ID string `db:"id" json:"id"` Slug null.String `db:"slug" json:"slug,omitempty"` Font string `db:"text_appearance" json:"appearance"` Language zero.String `db:"language" json:"language"` RTL zero.Bool `db:"rtl" json:"rtl"` Privacy int64 `db:"privacy" json:"-"` OwnerID null.Int `db:"owner_id" json:"-"` CollectionID null.Int `db:"collection_id" json:"-"` PinnedPosition null.Int `db:"pinned_position" json:"-"` Created time.Time `db:"created" json:"created"` Updated time.Time `db:"updated" json:"updated"` ViewCount int64 `db:"view_count" json:"-"` Title zero.String `db:"title" json:"title"` HTMLTitle template.HTML `db:"title" json:"-"` Content string `db:"content" json:"body"` HTMLContent template.HTML `db:"content" json:"-"` HTMLExcerpt template.HTML `db:"content" json:"-"` Tags []string `json:"tags"` Images []string `json:"images,omitempty"` IsPaid bool `json:"paid"` OwnerName string `json:"owner,omitempty"` } // PublicPost holds properties for a publicly returned post, i.e. a post in // a context where the viewer may not be the owner. As such, sensitive // metadata for the post is hidden and properties supporting the display of // the post are added. PublicPost struct { *Post IsSubdomain bool `json:"-"` IsTopLevel bool `json:"-"` DisplayDate string `json:"-"` Views int64 `json:"views"` Owner *PublicUser `json:"-"` IsOwner bool `json:"-"` URL string `json:"url,omitempty"` Collection *CollectionObj `json:"collection,omitempty"` } CollectionPostPage struct { *PublicPost page.StaticPage IsOwner bool IsPinned bool IsCustomDomain bool Monetization string Verification string PinnedPosts *[]PublicPost IsFound bool IsAdmin bool CanInvite bool Silenced bool // Helper field for Chorus mode CollAlias string } RawPost struct { Id, Slug string Title string Content string Views int64 Font string Created time.Time Updated time.Time IsRTL sql.NullBool Language sql.NullString OwnerID int64 CollectionID sql.NullInt64 Found bool Gone bool } AnonymousAuthPost struct { ID string `json:"id"` Token string `json:"token"` } ClaimPostRequest struct { *AnonymousAuthPost CollectionAlias string `json:"collection"` CreateCollection bool `json:"create_collection"` // Generated properties Slug string `json:"-"` } ClaimPostResult struct { ID string `json:"id,omitempty"` Code int `json:"code,omitempty"` ErrorMessage string `json:"error_msg,omitempty"` Post *PublicPost `json:"post,omitempty"` } ) func (p *Post) Direction() string { if p.RTL.Valid { if p.RTL.Bool { return "rtl" } return "ltr" } return "auto" } // DisplayTitle dynamically generates a title from the Post's contents if it // doesn't already have an explicit title. func (p *Post) DisplayTitle() string { if p.Title.String != "" { return p.Title.String } t := friendlyPostTitle(p.Content, p.ID) return t } // PlainDisplayTitle dynamically generates a title from the Post's contents if it // doesn't already have an explicit title. func (p *Post) PlainDisplayTitle() string { if t := stripmd.Strip(p.DisplayTitle()); t != "" { return t } return p.ID } // FormattedDisplayTitle dynamically generates a title from the Post's contents if it // doesn't already have an explicit title. func (p *Post) FormattedDisplayTitle() template.HTML { if p.HTMLTitle != "" { return p.HTMLTitle } return template.HTML(p.DisplayTitle()) } // Summary gives a shortened summary of the post based on the post's title, // especially for display in a longer list of posts. It extracts a summary for // posts in the Title\n\nBody format, returning nothing if the entire was short // enough that the extracted title == extracted summary. func (p Post) Summary() string { if p.Content == "" { return "" } p.Content = stripHTMLWithoutEscaping(p.Content) // and Markdown p.Content = stripmd.StripOptions(p.Content, stripmd.Options{SkipImages: true}) title := p.Title.String var desc string if title == "" { // No title, so generate one title = friendlyPostTitle(p.Content, p.ID) desc = postDescription(p.Content, title, p.ID) if desc == title { return "" } return desc } return shortPostDescription(p.Content) } func (p Post) SummaryHTML() template.HTML { return template.HTML(p.Summary()) } // Excerpt shows any text that comes before a (more) tag. // TODO: use HTMLExcerpt in templates instead of this method func (p *Post) Excerpt() template.HTML { return p.HTMLExcerpt } func (p *Post) CreatedDate() string { return p.Created.Format("2006-01-02") } func (p *Post) Created8601() string { return p.Created.Format("2006-01-02T15:04:05Z") } func (p *Post) IsScheduled() bool { return p.Created.After(time.Now()) } func (p *Post) HasTag(tag string) bool { // Regexp looks for tag and has a non-capturing group at the end looking // for the end of the word. // Assisted by: https://stackoverflow.com/a/35192941/1549194 hasTag, _ := regexp.MatchString("#"+tag+`(?:[[:punct:]]|\s|\z)`, p.Content) return hasTag } func (p *Post) HasTitleLink() bool { if p.Title.String == "" { return false } hasLink, _ := regexp.MatchString(`([^!]+|^)\[.+\]\(.+\)`, p.Title.String) return hasLink } func (c CollectionPostPage) DisplayMonetization() string { if c.Collection == nil { log.Info("CollectionPostPage.DisplayMonetization: c.Collection is nil") return "" } return displayMonetization(c.Monetization, c.Collection.Alias) } func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) friendlyID := vars["post"] // NOTE: until this is done better, be sure to keep this in parity with // isRaw() and viewCollectionPost() isJSON := strings.HasSuffix(friendlyID, ".json") isXML := strings.HasSuffix(friendlyID, ".xml") isCSS := strings.HasSuffix(friendlyID, ".css") isMarkdown := strings.HasSuffix(friendlyID, ".md") isRaw := strings.HasSuffix(friendlyID, ".txt") || isJSON || isXML || isCSS || isMarkdown // Display reserved page if that is requested resource if t, ok := pages[r.URL.Path[1:]+".tmpl"]; ok { return handleTemplatedPage(app, w, r, t) } else if (strings.Contains(r.URL.Path, ".") && !isRaw && !isMarkdown) || r.URL.Path == "/robots.txt" || r.URL.Path == "/manifest.json" { // Serve static file app.shttp.ServeHTTP(w, r) return nil } // Display collection if this is a collection c, _ := app.db.GetCollection(friendlyID) if c != nil { return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", friendlyID)} } // Normalize the URL, redirecting user to consistent post URL if friendlyID != strings.ToLower(friendlyID) { return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s", strings.ToLower(friendlyID))} } ext := "" if isRaw { parts := strings.Split(friendlyID, ".") friendlyID = parts[0] if len(parts) > 1 { ext = "." + parts[1] } } var ownerID sql.NullInt64 var title string var content string var font string var language []byte var rtl []byte var views int64 var post *AnonymousPost var found bool var gone bool fixedID := slug.Make(friendlyID) if fixedID != friendlyID { return impart.HTTPError{http.StatusFound, fmt.Sprintf("/%s%s", fixedID, ext)} } err := app.db.QueryRow("SELECT owner_id, title, content, text_appearance, view_count, language, rtl FROM posts WHERE id = ?", friendlyID).Scan(&ownerID, &title, &content, &font, &views, &language, &rtl) switch { case err == sql.ErrNoRows: found = false // Output the error in the correct format if isJSON { content = "{\"error\": \"Post not found.\"}" } else if isRaw { content = "Post not found." } else { return ErrPostNotFound } case err != nil: found = false log.Error("Post loading err: %s\n", err) return ErrInternalGeneral default: found = true var d string if len(rtl) == 0 { d = "auto" } else if rtl[0] == 49 { // TODO: find a cleaner way to get this (possibly NULL) value d = "rtl" } else { d = "ltr" } generatedTitle := friendlyPostTitle(content, friendlyID) sanitizedContent := content if font != "code" { sanitizedContent = template.HTMLEscapeString(content) } var desc string if title == "" { desc = postDescription(content, title, friendlyID) } else { desc = shortPostDescription(content) } post = &AnonymousPost{ ID: friendlyID, Content: sanitizedContent, Title: title, GenTitle: generatedTitle, Description: desc, Author: "", Font: font, IsPlainText: isRaw, IsCode: font == "code", IsLinkable: font != "code", Views: views, Language: string(language), Direction: d, } if !isRaw { post.HTMLContent = template.HTML(applyMarkdown([]byte(content), "", app.cfg)) post.Images = extractImages(post.Content) } } var silenced bool if found { silenced, err = app.db.IsUserSilenced(ownerID.Int64) if err != nil { log.Error("view post: %v", err) } } // Check if post has been unpublished if title == "" && content == "" { gone = true if isJSON { content = "{\"error\": \"Post was unpublished.\"}" } else if isCSS { content = "" } else if isRaw { content = "Post was unpublished." } else { return ErrPostUnpublished } } var u = &User{} if isRaw { contentType := "text/plain" if isJSON { contentType = "application/json" } else if isCSS { contentType = "text/css" } else if isXML { contentType = "application/xml" } else if isMarkdown { contentType = "text/markdown" } w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType)) if isMarkdown && post.Title != "" { fmt.Fprintf(w, "%s\n", post.Title) for i := 1; i <= len(post.Title); i++ { fmt.Fprintf(w, "=") } fmt.Fprintf(w, "\n\n") } fmt.Fprint(w, content) if !found { return ErrPostNotFound } else if gone { return ErrPostUnpublished } } else { var err error page := struct { *AnonymousPost page.StaticPage Username string IsOwner bool SiteURL string Silenced bool }{ AnonymousPost: post, StaticPage: pageForReq(app, r), SiteURL: app.cfg.App.Host, } if u = getUserSession(app, r); u != nil { page.Username = u.Username page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID } if !page.IsOwner && silenced { return ErrPostNotFound } page.Silenced = silenced err = templates["post"].ExecuteTemplate(w, "post", page) if err != nil { log.Error("Post template execute error: %v", err) } } go func() { if u != nil && ownerID.Valid && ownerID.Int64 == u.ID { // Post is owned by someone; skip view increment since that person is viewing this post. return } // Update stats for non-raw post views if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) { _, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE id = ?", friendlyID) if err != nil { log.Error("Unable to update posts count: %v", err) } } }() return nil } // API v2 funcs // newPost creates a new post with or without an owning Collection. // // Endpoints: // - /posts // - /posts?collection={alias} // - ? /collections/{alias}/posts func newPost(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) vars := mux.Vars(r) collAlias := vars["alias"] if collAlias == "" { collAlias = r.FormValue("collection") } accessToken := r.Header.Get("Authorization") if accessToken == "" { // TODO: remove this accessToken = r.FormValue("access_token") } // FIXME: determine web submission with Content-Type header var u *User var userID int64 = -1 var username string if accessToken == "" { u = getUserSession(app, r) if u != nil { userID = u.ID username = u.Username } } else { userID = app.db.GetUserID(accessToken) } silenced, err := app.db.IsUserSilenced(userID) if err != nil { log.Error("new post: %v", err) } if silenced { return ErrUserSilenced } if userID == -1 { return ErrNotLoggedIn } if accessToken == "" && u == nil && collAlias != "" { return impart.HTTPError{http.StatusBadRequest, "Parameter `access_token` required."} } // Get post data var p *SubmittedPost if reqJSON { decoder := json.NewDecoder(r.Body) err = decoder.Decode(&p) if err != nil { log.Error("Couldn't parse new post JSON request: %v\n", err) return ErrBadJSON } if p.Title == nil { t := "" p.Title = &t } if strings.TrimSpace(*(p.Title)) == "" && (p.Content == nil || strings.TrimSpace(*(p.Content)) == "") { return ErrNoPublishableContent } if p.Content == nil { c := "" p.Content = &c } } else { post := r.FormValue("body") appearance := r.FormValue("font") title := r.FormValue("title") rtlValue := r.FormValue("rtl") langValue := r.FormValue("lang") if strings.TrimSpace(post) == "" { return ErrNoPublishableContent } var isRTL, rtlValid bool if rtlValue == "auto" && langValue != "" { isRTL = i18n.LangIsRTL(langValue) rtlValid = true } else { isRTL = rtlValue == "true" rtlValid = rtlValue != "" && langValue != "" } // Create a new post p = &SubmittedPost{ Title: &title, Content: &post, Font: appearance, IsRTL: converter.NullJSONBool{sql.NullBool{Bool: isRTL, Valid: rtlValid}}, Language: converter.NullJSONString{sql.NullString{String: langValue, Valid: langValue != ""}}, } } if !p.isFontValid() { p.Font = "norm" } var newPost *PublicPost = &PublicPost{} var coll *Collection if accessToken != "" { newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias, app.cfg.App.Host) } else { //return ErrNotLoggedIn // TODO: verify user is logged in var collID int64 if collAlias != "" { coll, err = app.db.GetCollection(collAlias) if err != nil { return err } coll.hostName = app.cfg.App.Host if coll.OwnerID != u.ID { return ErrForbiddenCollection } collID = coll.ID } // TODO: return PublicPost from createPost newPost.Post, err = app.db.CreatePost(userID, collID, p) } if err != nil { return err } if coll != nil { coll.ForPublic() newPost.Collection = &CollectionObj{Collection: *coll} } newPost.extractData() newPost.OwnerName = username newPost.URL = newPost.CanonicalURL(app.cfg.App.Host) // Write success now response := impart.WriteSuccess(w, newPost, http.StatusCreated) if newPost.Collection != nil { if !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) { go federatePost(app, newPost, newPost.Collection.ID, false) } - if app.cfg.Letters.Enabled() && newPost.Collection.EmailSubsEnabled() { + if app.cfg.Email.Enabled() && newPost.Collection.EmailSubsEnabled() { go app.db.InsertJob(&PostJob{ PostID: newPost.ID, Action: "email", Delay: emailSendDelay, }) } } return response } func existingPost(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) vars := mux.Vars(r) postID := vars["post"] p := AuthenticatedPost{ID: postID} var err error if reqJSON { // Decode JSON request decoder := json.NewDecoder(r.Body) err = decoder.Decode(&p) if err != nil { log.Error("Couldn't parse post update JSON request: %v\n", err) return ErrBadJSON } } else { err = r.ParseForm() if err != nil { log.Error("Couldn't parse post update form request: %v\n", err) return ErrBadFormData } // Can't decode to a nil SubmittedPost property, so create instance now p.SubmittedPost = &SubmittedPost{} err = app.formDecoder.Decode(&p, r.PostForm) if err != nil { log.Error("Couldn't decode post update form request: %v\n", err) return ErrBadFormData } } if p.Web { p.IsRTL.Valid = true } if p.SubmittedPost == nil { return ErrPostNoUpdatableVals } // Ensure an access token was given accessToken := r.Header.Get("Authorization") // Get user's cookie session if there's no token var u *User //var username string if accessToken == "" { u = getUserSession(app, r) if u != nil { //username = u.Username } } if u == nil && accessToken == "" { return ErrNoAccessToken } // Get user ID from current session or given access token, if one was given. var userID int64 if u != nil { userID = u.ID } else if accessToken != "" { userID, err = AuthenticateUser(app.db, accessToken) if err != nil { return err } } silenced, err := app.db.IsUserSilenced(userID) if err != nil { log.Error("existing post: %v", err) } if silenced { return ErrUserSilenced } // Modify post struct p.ID = postID err = app.db.UpdateOwnedPost(&p, userID) if err != nil { if reqJSON { return err } if err, ok := err.(impart.HTTPError); ok { addSessionFlash(app, w, r, err.Message, nil) } else { addSessionFlash(app, w, r, err.Error(), nil) } } var pRes *PublicPost pRes, err = app.db.GetPost(p.ID, 0) if reqJSON { if err != nil { return err } pRes.extractData() } if pRes.CollectionID.Valid { coll, err := app.db.GetCollectionBy("id = ?", pRes.CollectionID.Int64) if err == nil && !app.cfg.App.Private && app.cfg.App.Federation { coll.hostName = app.cfg.App.Host pRes.Collection = &CollectionObj{Collection: *coll} go federatePost(app, pRes, pRes.Collection.ID, true) } } // Write success now if reqJSON { return impart.WriteSuccess(w, pRes, http.StatusOK) } addSessionFlash(app, w, r, "Changes saved.", nil) collectionAlias := vars["alias"] redirect := "/" + postID + "/meta" if collectionAlias != "" { collPre := "/" + collectionAlias if app.cfg.App.SingleUser { collPre = "" } redirect = collPre + "/" + pRes.Slug.String + "/edit/meta" } else { if app.cfg.App.SingleUser { redirect = "/d" + redirect } } w.Header().Set("Location", redirect) w.WriteHeader(http.StatusFound) return nil } func deletePost(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) friendlyID := vars["post"] editToken := r.FormValue("token") var ownerID int64 var u *User accessToken := r.Header.Get("Authorization") if accessToken == "" && editToken == "" { u = getUserSession(app, r) if u == nil { return ErrNoAccessToken } } var res sql.Result var t *sql.Tx var err error var collID sql.NullInt64 var coll *Collection var pp *PublicPost if editToken != "" { // TODO: SELECT owner_id, as well, and return appropriate error if NULL instead of running two queries var dummy int64 err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ?", friendlyID).Scan(&dummy) switch { case err == sql.ErrNoRows: return impart.HTTPError{http.StatusNotFound, "Post not found."} } err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND owner_id IS NULL", friendlyID).Scan(&dummy) switch { case err == sql.ErrNoRows: // Post already has an owner. This could provide a bad experience // for the user, but it's more important to ensure data isn't lost // unexpectedly. So prevent deletion via token. return impart.HTTPError{http.StatusConflict, "This post belongs to some user (hopefully yours). Please log in and delete it from that user's account."} } res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND modify_token = ? AND owner_id IS NULL", friendlyID, editToken) } else if accessToken != "" || u != nil { // Caller provided some way to authenticate; assume caller expects the // post to be deleted based on a specific post owner, thus we should // return corresponding errors. if accessToken != "" { ownerID = app.db.GetUserID(accessToken) if ownerID == -1 { return ErrBadAccessToken } } else { ownerID = u.ID } // TODO: don't make two queries var realOwnerID sql.NullInt64 err = app.db.QueryRow("SELECT collection_id, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&collID, &realOwnerID) if err != nil { return err } if !collID.Valid { // There's no collection; simply delete the post res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID) } else { // Post belongs to a collection; do any additional clean up coll, err = app.db.GetCollectionBy("id = ?", collID.Int64) if err != nil { log.Error("Unable to get collection: %v", err) return err } if app.cfg.App.Federation { // First fetch full post for federation pp, err = app.db.GetOwnedPost(friendlyID, ownerID) if err != nil { log.Error("Unable to get owned post: %v", err) return err } collObj := &CollectionObj{Collection: *coll} pp.Collection = collObj } t, err = app.db.Begin() if err != nil { log.Error("No begin: %v", err) return err } res, err = t.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID) } } else { return impart.HTTPError{http.StatusBadRequest, "No authenticated user or post token given."} } if err != nil { return err } affected, err := res.RowsAffected() if err != nil { if t != nil { t.Rollback() log.Error("Rows affected err! Rolling back") } return err } else if affected == 0 { if t != nil { t.Rollback() log.Error("No rows affected! Rolling back") } return impart.HTTPError{http.StatusForbidden, "Post not found, or you're not the owner."} } if t != nil { t.Commit() } if coll != nil && !app.cfg.App.Private && app.cfg.App.Federation { go deleteFederatedPost(app, pp, collID.Int64) } return impart.HTTPError{Status: http.StatusNoContent} } // addPost associates a post with the authenticated user. func addPost(app *App, w http.ResponseWriter, r *http.Request) error { var ownerID int64 // Authenticate user at := r.Header.Get("Authorization") if at != "" { ownerID = app.db.GetUserID(at) if ownerID == -1 { return ErrBadAccessToken } } else { u := getUserSession(app, r) if u == nil { return ErrNotLoggedIn } ownerID = u.ID } silenced, err := app.db.IsUserSilenced(ownerID) if err != nil { log.Error("add post: %v", err) } if silenced { return ErrUserSilenced } // Parse claimed posts in format: // [{"id": "...", "token": "..."}] var claims *[]ClaimPostRequest decoder := json.NewDecoder(r.Body) err = decoder.Decode(&claims) if err != nil { return ErrBadJSONArray } vars := mux.Vars(r) collAlias := vars["alias"] // Update all given posts res, err := app.db.ClaimPosts(app.cfg, ownerID, collAlias, claims) if err != nil { return err } for _, pRes := range *res { if pRes.Code != http.StatusOK { continue } if !app.cfg.App.Private && app.cfg.App.Federation { if !pRes.Post.Created.After(time.Now()) { pRes.Post.Collection.hostName = app.cfg.App.Host go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false) } } - if app.cfg.Letters.Enabled() && pRes.Post.Collection.EmailSubsEnabled() { + if app.cfg.Email.Enabled() && pRes.Post.Collection.EmailSubsEnabled() { go app.db.InsertJob(&PostJob{ PostID: pRes.Post.ID, Action: "email", Delay: emailSendDelay, }) } } return impart.WriteSuccess(w, res, http.StatusOK) } func dispersePost(app *App, w http.ResponseWriter, r *http.Request) error { var ownerID int64 // Authenticate user at := r.Header.Get("Authorization") if at != "" { ownerID = app.db.GetUserID(at) if ownerID == -1 { return ErrBadAccessToken } } else { u := getUserSession(app, r) if u == nil { return ErrNotLoggedIn } ownerID = u.ID } // Parse posts in format: // ["..."] var postIDs []string decoder := json.NewDecoder(r.Body) err := decoder.Decode(&postIDs) if err != nil { return ErrBadJSONArray } // Update all given posts res, err := app.db.DispersePosts(ownerID, postIDs) if err != nil { return err } return impart.WriteSuccess(w, res, http.StatusOK) } type ( PinPostResult struct { ID string `json:"id,omitempty"` Code int `json:"code,omitempty"` ErrorMessage string `json:"error_msg,omitempty"` } ) // pinPost pins a post to a blog func pinPost(app *App, w http.ResponseWriter, r *http.Request) error { var userID int64 // Authenticate user at := r.Header.Get("Authorization") if at != "" { userID = app.db.GetUserID(at) if userID == -1 { return ErrBadAccessToken } } else { u := getUserSession(app, r) if u == nil { return ErrNotLoggedIn } userID = u.ID } silenced, err := app.db.IsUserSilenced(userID) if err != nil { log.Error("pin post: %v", err) } if silenced { return ErrUserSilenced } // Parse request var posts []struct { ID string `json:"id"` Position int64 `json:"position"` } decoder := json.NewDecoder(r.Body) err = decoder.Decode(&posts) if err != nil { return ErrBadJSONArray } // Validate data vars := mux.Vars(r) collAlias := vars["alias"] coll, err := app.db.GetCollection(collAlias) if err != nil { return err } if coll.OwnerID != userID { return ErrForbiddenCollection } // Do (un)pinning isPinning := r.URL.Path[strings.LastIndex(r.URL.Path, "/"):] == "/pin" res := []PinPostResult{} for _, p := range posts { err = app.db.UpdatePostPinState(isPinning, p.ID, coll.ID, userID, p.Position) ppr := PinPostResult{ID: p.ID} if err != nil { ppr.Code = http.StatusInternalServerError // TODO: set error messsage } else { ppr.Code = http.StatusOK } res = append(res, ppr) } return impart.WriteSuccess(w, res, http.StatusOK) } func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error { var collID int64 var coll *Collection var err error vars := mux.Vars(r) if collAlias := vars["alias"]; collAlias != "" { // Fetch collection information, since an alias is provided coll, err = app.db.GetCollection(collAlias) if err != nil { return err } collID = coll.ID } p, err := app.db.GetPost(vars["post"], collID) if err != nil { return err } if coll == nil && p.CollectionID.Valid { // Collection post is getting fetched by post ID, not coll alias + post slug, so get coll info now. coll, err = app.db.GetCollectionByID(p.CollectionID.Int64) if err != nil { return err } } if coll != nil { coll.hostName = app.cfg.App.Host _, err = apiCheckCollectionPermissions(app, r, coll) if err != nil { return err } } silenced, err := app.db.IsUserSilenced(p.OwnerID.Int64) if err != nil { log.Error("fetch post: %v", err) } if silenced { return ErrPostNotFound } p.extractData() if IsActivityPubRequest(r) { if coll == nil { // This is a draft post; 404 for now // TODO: return ActivityObject return impart.HTTPError{http.StatusNotFound, ""} } p.Collection = &CollectionObj{Collection: *coll} po := p.ActivityObject(app) po.Context = []interface{}{activitystreams.Namespace} setCacheControl(w, apCacheTime) return impart.RenderActivityJSON(w, po, http.StatusOK) } return impart.WriteSuccess(w, p, http.StatusOK) } func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) p, err := app.db.GetPostProperty(vars["post"], 0, vars["property"]) if err != nil { return err } return impart.WriteSuccess(w, p, http.StatusOK) } func (p *Post) processPost() PublicPost { res := &PublicPost{Post: p, Views: 0} res.Views = p.ViewCount // TODO: move to own function loc := monday.FuzzyLocale(p.Language.String) res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc) return *res } func (p *PublicPost) CanonicalURL(hostName string) string { if p.Collection == nil || p.Collection.Alias == "" { return hostName + "/" + p.ID + ".md" } return p.Collection.CanonicalURL() + p.Slug.String } func (pp *PublicPost) DisplayCanonicalURL() string { us := pp.CanonicalURL(pp.Collection.hostName) u, err := url.Parse(us) if err != nil { return us } return u.Hostname() + u.Path } func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object { cfg := app.cfg var o *activitystreams.Object if cfg.App.NotesOnly || strings.Index(p.Content, "\n\n") == -1 { o = activitystreams.NewNoteObject() } else { o = activitystreams.NewArticleObject() } o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID o.Published = p.Created o.URL = p.CanonicalURL(cfg.App.Host) o.AttributedTo = p.Collection.FederatedAccount() o.CC = []string{ p.Collection.FederatedAccount() + "/followers", } o.Name = p.DisplayTitle() p.augmentContent() if p.HTMLContent == template.HTML("") { p.formatContent(cfg, false, false) p.augmentReadingDestination() } o.Content = string(p.HTMLContent) if p.Language.Valid { o.ContentMap = map[string]string{ p.Language.String: string(p.HTMLContent), } } if len(p.Tags) == 0 { o.Tag = []activitystreams.Tag{} } else { var tagBaseURL string if isSingleUser { tagBaseURL = p.Collection.CanonicalURL() + "tag:" } else { if cfg.App.Chorus { tagBaseURL = fmt.Sprintf("%s/read/t/", p.Collection.hostName) } else { tagBaseURL = fmt.Sprintf("%s/%s/tag:", p.Collection.hostName, p.Collection.Alias) } } for _, t := range p.Tags { o.Tag = append(o.Tag, activitystreams.Tag{ Type: activitystreams.TagHashtag, HRef: tagBaseURL + t, Name: "#" + t, }) } } if len(p.Images) > 0 { for _, i := range p.Images { o.Attachment = append(o.Attachment, activitystreams.NewImageAttachment(i)) } } // Find mentioned users mentionedUsers := make(map[string]string) stripper := bluemonday.StrictPolicy() content := stripper.Sanitize(p.Content) mentions := mentionReg.FindAllString(content, -1) for _, handle := range mentions { actorIRI, err := app.db.GetProfilePageFromHandle(app, handle) if err != nil { log.Info("Couldn't find user '%s' locally or remotely", handle) continue } mentionedUsers[handle] = actorIRI } for handle, iri := range mentionedUsers { o.CC = append(o.CC, iri) o.Tag = append(o.Tag, activitystreams.Tag{Type: "Mention", HRef: iri, Name: handle}) } return o } // TODO: merge this into getSlugFromPost or phase it out func getSlug(title, lang string) string { return getSlugFromPost("", title, lang) } func getSlugFromPost(title, body, lang string) string { if title == "" { // Remove Markdown, so e.g. link URLs and image alt text don't make it into the slug body = strings.TrimSpace(stripmd.StripOptions(body, stripmd.Options{SkipImages: true})) title = postTitle(body, body) } title = parse.PostLede(title, false) // Truncate lede if needed title, _ = parse.TruncToWord(title, 80) var s string if lang != "" && len(lang) == 2 { s = slug.MakeLang(title, lang) } else { s = slug.Make(title) } // Transliteration may cause the slug to expand past the limit, so truncate again s, _ = parse.TruncToWord(s, 80) return strings.TrimFunc(s, func(r rune) bool { // TruncToWord doesn't respect words in a slug, since spaces are replaced // with hyphens. So remove any trailing hyphens. return r == '-' }) } // isFontValid returns whether or not the submitted post's appearance is valid. func (p *SubmittedPost) isFontValid() bool { validFonts := map[string]bool{ "norm": true, "sans": true, "mono": true, "wrap": true, "code": true, } _, valid := validFonts[p.Font] return valid } func getRawPost(app *App, friendlyID string) *RawPost { var content, font, title string var isRTL sql.NullBool var lang sql.NullString var ownerID sql.NullInt64 var created, updated time.Time err := app.db.QueryRow("SELECT title, content, text_appearance, language, rtl, created, updated, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&title, &content, &font, &lang, &isRTL, &created, &updated, &ownerID) switch { case err == sql.ErrNoRows: return &RawPost{Content: "", Found: false, Gone: false} case err != nil: log.Error("Unable to fetch getRawPost: %s", err) return &RawPost{Content: "", Found: true, Gone: false} } return &RawPost{ Title: title, Content: content, Font: font, Created: created, Updated: updated, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == "" && title == "", } } // TODO; return a Post! func getRawCollectionPost(app *App, slug, collAlias string) *RawPost { var id, title, content, font string var isRTL sql.NullBool var lang sql.NullString var created, updated time.Time var ownerID null.Int var views int64 var err error if app.cfg.App.SingleUser { err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, updated, owner_id FROM posts WHERE slug = ? AND collection_id = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &updated, &ownerID) } else { err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, updated, owner_id FROM posts WHERE slug = ? AND collection_id = (SELECT id FROM collections WHERE alias = ?)", slug, collAlias).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &updated, &ownerID) } switch { case err == sql.ErrNoRows: return &RawPost{Content: "", Found: false, Gone: false} case err != nil: log.Error("Unable to fetch getRawCollectionPost: %s", err) return &RawPost{Content: "", Found: true, Gone: false} } return &RawPost{ Id: id, Slug: slug, Title: title, Content: content, Font: font, Created: created, Updated: updated, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == "" && title == "", Views: views, } } func isRaw(r *http.Request) bool { vars := mux.Vars(r) slug := vars["slug"] // NOTE: until this is done better, be sure to keep this in parity with // isRaw in viewCollectionPost() and handleViewPost() isJSON := strings.HasSuffix(slug, ".json") isXML := strings.HasSuffix(slug, ".xml") isMarkdown := strings.HasSuffix(slug, ".md") return strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown } func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) slug := vars["slug"] // NOTE: until this is done better, be sure to keep this in parity with // isRaw() and handleViewPost() isJSON := strings.HasSuffix(slug, ".json") isXML := strings.HasSuffix(slug, ".xml") isMarkdown := strings.HasSuffix(slug, ".md") isRaw := strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown cr := &collectionReq{} err := processCollectionRequest(cr, vars, w, r) if err != nil { return err } // Check for hellbanned users u, err := checkUserForCollection(app, cr, r, true) if err != nil { return err } // Normalize the URL, redirecting user to consistent post URL if slug != strings.ToLower(slug) { loc := fmt.Sprintf("/%s", strings.ToLower(slug)) if !app.cfg.App.SingleUser { loc = "/" + cr.alias + loc } return impart.HTTPError{http.StatusMovedPermanently, loc} } // Display collection if this is a collection var c *Collection if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(cr.alias) } if err != nil { if err, ok := err.(impart.HTTPError); ok { if err.Status == http.StatusNotFound { // Redirect if necessary newAlias := app.db.GetCollectionRedirect(cr.alias) if newAlias != "" { return impart.HTTPError{http.StatusFound, "/" + newAlias + "/" + slug} } } } return err } c.hostName = app.cfg.App.Host silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("view collection post: %v", err) } // Check collection permissions if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) { return ErrPostNotFound } if c.IsProtected() && (u == nil || u.ID != c.OwnerID) { if silenced { return ErrPostNotFound } else if !isAuthorizedForCollection(app, c.Alias, r) { return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug} } } cr.isCollOwner = u != nil && c.OwnerID == u.ID if isRaw { slug = strings.Split(slug, ".")[0] } // Fetch extra data about the Collection // TODO: refactor out this logic, shared in collection.go:fetchCollection() coll := NewCollectionObj(c) owner, err := app.db.GetUserByID(coll.OwnerID) if err != nil { // Log the error and just continue log.Error("Error getting user for collection: %v", err) } else { coll.Owner = owner } postFound := true p, err := app.db.GetPost(slug, coll.ID) if err != nil { if err == ErrCollectionPageNotFound { postFound = false if slug == "feed" { // User tried to access blog feed without a trailing slash, and // there's no post with a slug "feed" return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "feed/"} } po := &Post{ Slug: null.NewString(slug, true), Font: "norm", Language: zero.NewString("en", true), RTL: zero.NewBool(false, true), Content: `

This page is missing.

Are you sure it was ever here?`, } pp := po.processPost() p = &pp } else { return err } } // Check if the authenticated user is the post owner p.IsOwner = u != nil && u.ID == p.OwnerID.Int64 p.Collection = coll p.IsTopLevel = app.cfg.App.SingleUser // Only allow a post owner or admin to view a post for silenced collections if silenced && !p.IsOwner && (u == nil || !u.IsAdmin()) { return ErrPostNotFound } // Check if post has been unpublished if p.Content == "" && p.Title.String == "" { return impart.HTTPError{http.StatusGone, "Post was unpublished."} } p.augmentContent() // Serve collection post if isRaw { contentType := "text/plain" if isJSON { contentType = "application/json" } else if isXML { contentType = "application/xml" } else if isMarkdown { contentType = "text/markdown" } w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType)) if !postFound { w.WriteHeader(http.StatusNotFound) fmt.Fprintf(w, "Post not found.") // TODO: return error instead, so status is correctly reflected in logs return nil } if isMarkdown && p.Title.String != "" { fmt.Fprintf(w, "# %s\n\n", p.Title.String) } fmt.Fprint(w, p.Content) } else if strings.Contains(r.Header.Get("Accept"), "application/activity+json") { if !postFound { return ErrCollectionPageNotFound } p.extractData() ap := p.ActivityObject(app) ap.Context = []interface{}{activitystreams.Namespace} setCacheControl(w, apCacheTime) return impart.RenderActivityJSON(w, ap, http.StatusOK) } else { p.extractData() p.Content = strings.Replace(p.Content, "", "", 1) - if app.cfg.Letters.Enabled() && c.EmailSubsEnabled() { + if app.cfg.Email.Enabled() && c.EmailSubsEnabled() { // TODO: indicate plan is inactive or subs disabled when OWNER is viewing their own post. if u != nil && u.IsEmailSubscriber(app, c.ID) { p.Content = strings.Replace(p.Content, "", `

You're subscribed to email updates. Unsubscribe.

`, -1) } else { p.Content = strings.Replace(p.Content, "", `
`, -1) } } p.Content = strings.Replace(p.Content, "<!--emailsub-->", "", 1) // TODO: move this to function p.formatContent(app.cfg, cr.isCollOwner, true) tp := CollectionPostPage{ PublicPost: p, StaticPage: pageForReq(app, r), IsOwner: cr.isCollOwner, IsCustomDomain: cr.isCustomDomain, IsFound: postFound, Silenced: silenced, CollAlias: c.Alias, } tp.IsAdmin = u != nil && u.IsAdmin() tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin) tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner) tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p) tp.Monetization = coll.Monetization tp.Verification = coll.Verification if !postFound { w.WriteHeader(http.StatusNotFound) } postTmpl := "collection-post" if app.cfg.App.Chorus { postTmpl = "chorus-collection-post" } if err := templates[postTmpl].ExecuteTemplate(w, "post", tp); err != nil { log.Error("Error in %s template: %v", postTmpl, err) } } go func() { if p.OwnerID.Valid { // Post is owned by someone. Don't update stats if owner is viewing the post. if u != nil && p.OwnerID.Int64 == u.ID { return } } // Update stats for non-raw post views if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) { _, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE slug = ? AND collection_id = ?", slug, coll.ID) if err != nil { log.Error("Unable to update posts count: %v", err) } } }() return nil } // TODO: move this to utils after making it more generic func PostsContains(sl *[]PublicPost, s *PublicPost) bool { for _, e := range *sl { if e.ID == s.ID { return true } } return false } func (p *Post) extractData() { p.Tags = tags.Extract(p.Content) p.extractImages() } func (p *Post) IsSans() bool { return p.Font == "sans" } func (p *Post) IsMonospace() bool { return p.Font == "mono" } func (rp *RawPost) UserFacingCreated() string { return rp.Created.Format(postMetaDateFormat) } func (rp *RawPost) Created8601() string { return rp.Created.Format("2006-01-02T15:04:05Z") } func (rp *RawPost) Updated8601() string { if rp.Updated.IsZero() { return "" } return rp.Updated.Format("2006-01-02T15:04:05Z") } var imageURLRegex = regexp.MustCompile(`(?i)[^ ]+\.(gif|png|jpg|jpeg|image)$`) func (p *Post) extractImages() { p.Images = extractImages(p.Content) } func extractImages(content string) []string { matches := extract.ExtractUrls(content) urls := map[string]bool{} for i := range matches { uRaw := matches[i].Text // Parse the extracted text so we can examine the path u, err := url.Parse(uRaw) if err != nil { continue } // Ensure the path looks like it leads to an image file if !imageURLRegex.MatchString(u.Path) { continue } urls[uRaw] = true } resURLs := make([]string, 0) for k := range urls { resURLs = append(resURLs, k) } return resURLs }